TypeScript의 타입 체크는 구조적(structural) 시스템을 따릅니다. 이는 아래와 같은 신기한(?) 특징을 가집니다.
interface Foo {
a: number
}
interface Bar {
a: number
b: string
}
const foo: Foo = { a: 3, b: 'b' } as Bar
위 코드는 Foo
에 Bar
를 할당하고 있습니다. Bar
에는 Foo
와는 다르게 추가적으로 b
가 존재합니다. 그런데도 위 코드는 정상 동작합니다. 이러한 이유는 TypeScript가 구조적 시스템을 따르기 때문입니다. Foo
가 되기 위해서는 a: number
만 있으면 되고, 이는 Bar
도 만족하기 때문에 위 코드가 정상 동작하는 것입니다. 즉, 구조적으로 타입이 맞기만 하면, 이를 허용해줍니다.
반면 명목적 시스템은 타입의 이름만 달라도 별개의 타입으로 취급합니다.
그런데 이렇게 구조적 시스템을 가지면 특정 값에 우리가 원하는 특정한 타입을 지정하지 못할 수도 있습니다. 아래의 예시 코드를 보면 이해가 될 것입니다.
type Address = string;
type Tel = string;
const telNumber: Tel = '010-010-010' as Address;
위 코드는 타입이 이상하지만 잘 동작합니다. 즉, 구조적 시스템은 구조만 같으면 된다는 문제점이 존재하는데, 이를 어느 정도 해결하기 위해 Branding 이라는 방법을 사용할 수 있습니다.
Branding(혹은 nominal) 타이핑이라는 기법은 주로 값의 원시 타입만으로는 충분하지 않을 때 혹은 두 개 이상의 값이 같은 원시 타입을 갖지만 서로 다른 용도나 의미를 갖게 하고 싶을 때 사용됩니다.
Brading 기법을 사용하면, 의도하지 않은 값의 할당이나 연산을 방지할 수 있습니다. 아래의 예시와 같이 단순한 String
으로 표현되는 UserID
와 OrderID
가 있다고 할 때, 둘 다 문자열이지만 두 ID가 혼동되면 안되는 상황에서 사용할 수 있습니다.
type Brand<Key extends string, Value> = Value & { __brand: Key }
type UserID = Brand<'UserID', string>
type OrderID = Brand<'OrderID', string>
let userId: UserID = "user123" as UserID;
let orderId: OrderID = "order456" as OrderID;
// 아래와 같은 할당은 타입 에러를 발생시킵니다.
// userId = orderId;
여기서 UserID
와 OrderID
는 모두 문자열 타입을 기반이지만, __brand 프로퍼티의 존재로 인해 각각 고유한 타입으로 취급됩니다. 따라서, UserID
에 OrderID
값을 할당하려고 시도하면 타입 에러가 발생합니다. 이러한 방식을 사용하면 값의 원시 타입만으로는 구분하기 어려운 경우에도 타입 안전성을 보장할 수 있습니다.
그런데 위의 Value & { __brand: Key }
부분이 헷갈릴 수도 있습니다. 왜냐하면 프리미티브 타입들은 객체가 아니어서 프로퍼티나 메서드를 가질 수 없기 때문이죠.
TypeScript에서는 프리미티브 타입과 객체 타입을 &
를 통해 합하면, 결과 타입은 해당 프리미티브 타입의 확장된 버전으로 간주됩니다. 아래에서 예시를 들어 좀 더 자세히 설명해보겠습니다.
type CustomString = string & { customProp: string };
CustomString
은 기본적으로 string 타입입니다. 그러나 일반적인 문자열로는 이 타입에 할당할 수 없습니다. CustomString
타입에 값을 할당하려면 해당 값은 .customProp
프로퍼티도 가지고 있어야 합니다. 그러나 우리가 이미 알고 있듯, 프리미티브 타입인 string은 프로퍼티를 가질 수 없습니다.
이렇게 만들어진 교차 타입은 실제로 값을 생성하는 것이 불가능해 보이지만, 주로 타입을 강제하는 것이나 타입의 제약을 더하기 위한 목적으로 사용됩니다. 실제로 값을 할당하려면 타입 단언을 사용해야 합니다.
const customStr: CustomString = "hello" as CustomString;
이렇게 타입 단언을 사용하면, TypeScript는 customStr
이 CustomString
타입임을 믿게 됩니다. 그러나 실제로 customStr
에는 .customProp
프로퍼티가 없습니다.
즉, 프리미티브 타입과 객체 타입을 &
를 사용해 교차하는 것은 타입 검사 및 제약을 위한 가상의 타입을 만드는데 사용됩니다. 실제로 그런 값은 생성할 수 없지만, 타입의 의도와 제약을 명확하게 표현하기 위한 도구로 사용될 수 있습니다.
위에서 알아본 것 처럼 브랜딩된 타입은 기본적으로 원시 타입에 추가적인 타입 정보를 부여하는 방식으로 작동합니다. 그러나 브랜딩된 타입의 값에 대해 연산을 수행하면, 결과는 정보를 잃어버리게 됩니다. 아래의 예시에서 좀 더 자세히 알아봅시다.
type UserID = string & { __brand: "UserID" };
function toUserID(value: string): UserID {
return value as UserID;
}
const userID1 = toUserID("12345");
const userID2 = toUserID("67890");
const combined = userID1 + userID2; // 결과는 string 타입입니다, UserID 타입이 아닙니다.
combined
의 결과는 string
타입 입니다. 브랜딩 정보를 유지하려면 추가적인 래핑 함수나 연산 처리 로직이 필요합니다. 아래는 이러한 문제를 해결하기 위해 사용할 수 있는 접근 방법들 입니다.
- 래핑 함수 사용
브랜딩된 타입에 대해 연산이나 변환을 수행하는 전용 함수를 제공합니다.
function createUserID(value: string): UserID { return value as UserID; }
function combineUserIDs(id1: UserID, id2: UserID): UserID {
return (id1 + id2) as UserID;
}
2. 클래스 래핑
> 브랜딩된 타입을 대신하여 클래스를 사용하여 값을 래핑할 수 있습니다. 클래스를 사용하여 값에 대한 접근을 제한하고, 특정 연산만을 허용하는 메서드를 제공하는 방법입니다.
class UserID {
private constructor(private value: string) {}
static create(value: string): UserID {
return new UserID(value);
}
combine(other: UserID): UserID {
return new UserID(this.value + other.value);
}
}
const userID1 = UserID.create("123");
const userID2 = userID1.combine(UserID.create("456")); // 유효한 연산
```
위의 방법들은 직접적으로 연산자의 사용을 제한하지는 않지만, 특정 연산의 사용을 권장하거나 제한하기 위한 방법들입니다. TypeScript는 컴파일 타임에 검사를 수행하기 때문에 이러한 제약사항들은 실행 시간에는 영향을 미치지 않습니다.
이상으로 TypeScript의 구조적 시스템과 branding 에 대해서 알아보았습니다.
참고
https://yceffort.kr/2021/06/typescript-structual-typing
https://sorto.me/posts/2021-05-12--nominal
'Develop > JavaScript' 카테고리의 다른 글
Refactoring 2판 3장 (0) | 2023.08.29 |
---|---|
리팩터링 2판 1장 (0) | 2023.06.01 |