Branded Type


타입스크립트를 사용하다보면 서로 다른 두 객체의 특정 속성이 동일한 타입을 가질 때가 많다. 이로 인해 타입 시스템에서 오류가 발생하지 않더라도 논리적 오류나 타입 안전성 문제를 야기할 수 있다.

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를 고유한 식별자로 사용하여 타입을 확장하고 있다. 예를 들어 Kstring으로 지정하면, 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>

개선된 Branded Type


위에서 사용한 __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 symbolconst 선언이나 클래스의 readonly static 속성에서만 사용할 수 있다.

declare 키워드는 실제 구현 없이 타입 정보만 선언할 때 사용한다. 주로 외부 라이브러리나 기존 모듈의 타입을 정의하여 타입스크립트 컴파일러에게 타입 정보를 알려주는 역할을 한다. declare로 선언된 내용은 컴파일 시 타입 체크에만 사용하고, 최종적으로 생성되는 자바스크립트 코드에는 포함되지 않는다.

Use Cases