타입스크립트를 사용하다보면 서로 다른 두 객체의 특정 속성이 동일한 타입을 가질 때가 많다. 이로 인해 타입 시스템에서 오류가 발생하지 않더라도 논리적 오류나 타입 안전성 문제를 야기할 수 있다.
type User = {
id: string;
name: string;
};
type Post = {
id: string;
ownerId: string;
comments: Comments[];
};
type Comments = {
id: string;
timestamp: string;
body: string;
authorId: string;
};
위 예시에서 User.id
, Post.id
, Comments.id
는 모두 string
타입이다. 첫 번째 인자 postId
, 두 번째 인자 authorId
를 받는 함수에 인자 순서를 반대로 전달해도, 두 타입이 문자열로 동일하기 때문에 오류를 감지하지 못한다. 타입 시스템에선 에러가 발생하지 않았지만, 런타임에선 순서가 바뀐 id
값 때문에 잘못된 응답을 받는 문제가 발생한다.
async function getCommentsForPost(postId: string, authorId: string) {
const response = await api.get(`/author/${authorId}/posts/${postId}/comments`);
return response.data;
}
const comments = await getCommentsForPost(user.id, post.id); // 타입에러 발생 안함
이런 문제를 방지하기 위해 브랜디드 타입(Branded Types)을 사용할 수 있다. 브랜디드 타입은 기존 타입에 고유한 식별자를 나타내는 프로퍼티(라벨)를 추가하여 보다 명확하고 구체적인 타입을 생성한다.
type Brand<K, T> = K & { __brand: T };
type UserID = Brand<string, 'UserId'>;
type PostID = Brand<string, 'PostId'>;
const userId = '123' as UserID;
console.log(typeof userId); // string
console.log(userId.__brand); // undefined
위 코드에서 Brand<K, T>
는 K
를 기본 타입으로, T
를 고유한 식별자로 사용하여 타입을 확장하고 있다. 예를 들어 K
를 string
으로 지정하면, string
타입에 __brand
라는 특수 프로퍼티가 붙은 새로운 타입이 생성된다.
이때 __brand
프로퍼티는 런타임에선 존재하지 않고, 타입스크립트 컴파일러가 서로 다른 브랜디드 타입을 구분할 때 사용하는 메타데이터다. 이를 통해 동일한 원시 타입인 string
을 사용하더라도, 컴파일러는 서로 다른 타입으로 인식하여 타입 안전성이 강화된다.
type Brand<K, T> = K & { __brand: T };
type UserID = Brand<string, 'UserId'>;
type PostID = Brand<string, 'PostId'>;
type CommentID = Brand<string, 'CommentId'>;
type User = {
id: UserID;
name: string;
};
type Post = {
id: PostID;
ownerId: string;
comments: Comments[];
};
type Comments = {
id: CommentID;
timestamp: string;
body: string;
authorId: UserID;
};
async function getCommentsForPost(postId: PostID, authorId: UserID) {
// ...
}
const user = {} as User;
const post = {} as Post;
// ❌ TS2345: Argument of type UserID is not assignable to parameter of type PostID
const comments = getCommentsForPost(user.id, post.id);
<aside> <img src="/icons/search_gray.svg" alt="/icons/search_gray.svg" width="40px" />
제네릭이 원시 타입일 때도 교차 타입을 사용하여(& 연산자) 타입 레벨에서 추가적인 속성을 부여할 수 있다. 타입을 할당할 때 교차 타입 특성에 따라 모든 특성을 포함한 구체적인 타입으로 결정되는데(두 타입을 모두 만족하는 하나의 타입 생성), 제네릭에 원시 타입을 넘겼다면 추가된 속성은 타입 안정성을 위한 용도로만 사용되고, 런타임에선 원시 타입으로 평가된다.
</aside>
위에서 사용한 __brand
프로퍼티는 런타임에는 존재하지 않지만, 코드 작성 시 자동 완성에 표시돼서 접근 가능해 보이는 단점이 있다. 아래처럼 유니크 심볼을 활용하면 중복 키 사용을 방지할 수 있고, declare
키워드와 함께 사용하여 타입 시스템에만 존재하는 __brand
심볼 프로퍼티를 정의해서 접근을 차단할 수 있다.
declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }
type Branded<T, B> = T & Brand<B>
type UserID = Branded<string, 'UserId'>;
const userId = '123' as UserID;
console.log(typeof userId); // string
console.log(userId.__brand); // TS2339: Property __brand does not exist on type UserID
unique symbol
은 타입 시스템에서 고유성을 더욱 강하게 보장하여 심볼 간의 충돌을 방지하기 위한 기능이다. 자바스크립트의 Symbol은 고유한 값을 가지지만, unique symbol
을 사용하면 타입 수준에서의 고유성까지 엄격하게 관리할 수 있다. unique symbol
은 const
선언이나 클래스의 readonly static
속성에서만 사용할 수 있다.
declare
키워드는 실제 구현 없이 타입 정보만 선언할 때 사용한다. 주로 외부 라이브러리나 기존 모듈의 타입을 정의하여 타입스크립트 컴파일러에게 타입 정보를 알려주는 역할을 한다. declare
로 선언된 내용은 컴파일 시 타입 체크에만 사용하고, 최종적으로 생성되는 자바스크립트 코드에는 포함되지 않는다.