개념
JS/TS는 structural typing을 한다. 그렇기 때문에 duck-typing과 같은 기능이 가능해진다. 그런데 이렇게 구조가 같다는 것으로 타입을 지정할 수 있다면 다음과 같은 문제가 발생한다. vec3D는 x,y,z가 있는데도 x,y만 가지고 있는 Vector2D에 할당 가능하게 된다.
interface Vector2D {
x: number;
y: number;
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
// 문제 발생
const vec3D = {x:3, y:4, z:5};
calculateNorm(vec3D);
이러한 구조적 타이핑(structural typing)의 문제를 해결하기 위한 방법으로 명시적 타이핑(nominal typing)을 사용하기 위해 'brand'라는 필드를 지정할 수 있다. 이 brand의 값의 "이름"으로 type을 구분하기 위한 방법이다. 위의 경우 2D를 의미하는 객체인 Vector2D에게 brand:'2d'; 라는 필드를 추가해 다음과 같이 x,y,z를 가진 vec3D가 Vector2D 파라미터로 들어가는 경우를 막을 수 있다.
interface Vector2D {
_brand: '2d';
x: number;
y: number;
}
function vec2D(x: number, y: number): Vector2D {
return { _brand: '2d', x, y };
}
function calculateNorm(p: Vector2D) {
return Math.sqrt(p.x * p.x + p.y * p.y);
}
calculateNorm(vec2D(3, 4)); // OK
// 에러를 발생시킨다
const vec3D = {x:3, y:4, z:5};
calculateNorm(vec3D); // Error: Argument of type '{ x: number; y: number; z: number; }' is not assignable to parameter of type 'Vector2D'.
[장점]
brand 를 이용하면 (1) calculateNorm 이 Vector2타입만 받는 것을 보장할 수 있으다. 타입 시스템이지만 런타임에 brand를 검사하는 것과 같은 (2)효과를 얻으면서도 런타임 오버헤드르 줄일 수 있고 (3)내장 타입인 string 이나 number도 brand를 붙일 수 있다.
특히 이 마지막 부분을 잘 이용하면 평범한 number 나 string 타입을 원하는 타입으로 만드는 데에 유용하게 사용할 수 있다.
아래 세 가지 예시에서 brand를 이용해 평범한 number 타입을 구체적으로 우리가 사용하고자하는 number 타입으로 지정하는 방법을 살펴보고자 한다.
예시 코드 (1) 책에서 나온 ts를 이용하는 방식
책의 나온 예시로,
type을 선언할 때 연산을 사용해 교집합, 다시 말해 string 이면서 {_brand: 'abs'}라는 필드-값을 가지도록 선언한다.
그리고 이 타입을 이용해 path is AbsolutePath라는 타입 가드를 이용, 특정 타입인지 타입 체커로 사용할 수 있도록 했다.
// ts 방식의 branded type
type AbsolutePath = string & { _brand: 'abs' };
function listAbsolutePath(path: AbsolutePath) {
// ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
return path.startsWith('/');
}
function foo(path: string) {
if (isAbsolutePath(path)) {
listAbsolutePath(path);
}
}
예시 코드 (2) Class 를 이용하는 방식
회사 분이 공유해주신 코드로,
타입 선언을 조금 더 추상화해서 원하는 타입(T)과 브랜드 타입(K)을 제너릭으로 받아서 사용할 수 있다.
type Branded<T, K> = T & { _brand: K };
예를 들면 아래와 같이 사용할 수 있다.
type Money = Branded<number, 'Money'>;
declare let money: Money
const money = 123 // error, Type 'number' is not assignable to type '{ _brand: "Money"; }'
const money2: Money = Money(123) & {'_brand': 'Money'}; // ok
Class 와 as 타입을 이용해서 타입체커로써 구현할 수 있다.
아래 checkMoney라는 함수를 보면 들어온 값이 0보다 작다면 에러를 던지고 타입에 맞다면 Money라는 타입을 강제해서 {_brand: "Money"}라는 값을 가지도록 한다.
// use function
function checkMoney(value: number): Money {
if (value < 0) {
throw new Error()
}
return value as Money
}
const money3: Money = checkMoney(123)
이를 위의 이펙티브TS 책의 내용에 대입해보면 아래와 같다. 제너릭T 로 타입을 선언하고 값 검증을 하는 타입체커를 선언한다.
// new Case
type MoneyNumber<T> = T & { _brand: 'Money' };
function isMoney<T>(m: T): m is MoneyNumber<T> {
return m > 0;
}
// use1: type guard
isMoney(10); // true
// use2: nominal typing
function f(mm: MoneyNumber<number>) {
return mm*2;
}
function calculateMoney(m: number) {
if(!isMoney(m)) {
throw new Error();
}
return f(m); // OK
}
calculateMoney(10);
예시 코드 (3) Symbol을 이용하는 방식
들어온 값에 대한 검증을 하는 또다른 방법으로 Symbol의 .toPrimitive를 이용하는 방법이 있다. 아래는 id가 undefined인 경우 에러를 발생시킨다. 여기서 Symbol.toPrimitive는 객체를 primitive value로 변환하는 방법을 결정하는 메서드를 정의하는 데 사용되는 JavaScript의 내장 심볼이다. _hint 를 이용해 어떤 타입을 선호하는 지 알려줄 수 있으며 이 과정에서 값을 검증할 수 있다.
// use class
class UserId2 {
constructor(private readonly id: number) {
}
[Symbol.toPrimitive](_hint: string) {
if (typeof this.id === 'undefined') {
throw new Error();
}
return this.id;
}
}
const userId = new UserId2(10);
'백엔드 개발' 카테고리의 다른 글
#031. 쿼리개선: N*M -> N+M 개선하기 (0) | 2023.04.09 |
---|---|
#030. CORS 에러 원인과 해결 (feat. 서버에서 CORS 테스트 하기) (0) | 2023.04.08 |
#029. 리팩터링: any 보다 unknown 타입 사용하기 (feat. 이중 단언문) (0) | 2023.04.05 |
#027. TS 타입 좁히기(1) Tagged Union (0) | 2023.03.08 |
#026. TS 의 타입 넓히기 (0) | 2023.02.07 |