원문 작성자: Matías Hernández, 참고 링크: Improve Runtime Type Safety with Branded Types in TypeScript
코드의 안전성과 신뢰성을 높이기 위해 타입스크립트를 사용한다는 것은 이미 잘 알려진 사실이다. 하지만 인생이 다 그렇듯 만능과 완벽은 존재하지 않는다.
특히 런타임 에러를 막기 위해서는 더 정교한 타입 시스템이 필요할 수 있다. 이때 브랜디드 타입(Branded Types)이 도움을 줄 수 있다.
이번 글에서는 브랜디드 타입(Branded Types)이 무엇인지, 어떻게 사용하는지, 그리고 어떤 장점이 있는지 살펴보겠다.
이 부분 알면 넘어가시고 모르시면 꼭 보시고 기본으로 깔고 가셔야합니다.
- 런타임(Runtime): 코드가 실제로 실행되는 시점. 런타임에 발생하는 에러는 코드가 실행되는 도중에 감지되므로, 문제 해결이 까다롭고 실행 중에 예기치 않은 동작을 유발할 수 있다.
- 빌드타임(Build Time): 코드를 작성하고 컴파일하는 시점으로, 타입스크립트는 이 단계에서 타입 검사를 수행한다.
잘못된 타입 사용이나 타입 불일치를 컴파일 타임에 미리 감지해줌으로써 런타임 에러를 줄일 수 있다.
타입스크립트는 주로 빌드타임에 타입 검사를 통해 코드를 검증하고, 안전성을 높인다.
그러나!!!!!! 타입스크립트의 기본 타입만으로는 런타임에 발생할 수 있는 모든 오류를 방지하기에는 부족할 때가 있다.
여기서 우리는 브랜디드 타입이 등장하게 된 배경을 알 수 있다.
타입스크립트는 빌드타임에 타입 검사를 통해 데이터가 올바르게 전달되는지 검증한다.
그러나 데이터의 구체성이나 특수성이 충분히 표현되지 않는 경우가 종종 발생한다.
예를 들어, 사용자, 게시물, 댓글을 다루는 애플리케이션을 만든다고 가정해보자.
type User = {
id: string;
name: string;
}
type Post = {
id: string;
ownerId: string;
comments: Comment[];
}
type Comment = {
id: string;
timestamp: string;
body: string;
authorId: string;
}
위 코드를 보면
User
,Post
,Comment
는 모두id
속성을 가지고 있다.
그러나 이 속성들은 모두 단순히string
타입이기 때문에, 타입스크립트 입장에서는 이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); // 타입스크립트는 에러를 인식하지 못함
위 코드에서는
getCommentsForPost
함수를 호출할 때 매개변수의 순서가 잘못되었다.
그러나 타입스크립트는user.id
와post.id
가 모두string
타입이므로 문제를 인식하지 못한다.
이 때문에 런타임에 잘못된 응답을 받게 되고, 디버깅이 어려워진다.
타입스크립트가 더욱 정교한 타입 안전성을 제공하기 위해 탄생한 것이 바로 "브랜디드 타입"이다.
브랜디드 타입은 기존 타입에 특정한 태그(brand)를 부여하여 더 구체적이고 유일한 타입을 만드는 방법이다.
이렇게 하면 단순한 기본 타입보다 더 명확하고 안전하게 데이터를 모델링할 수 있다.
type Brand<K, T> = K & { __brand: T };
type UserID = Brand<string, "UserId">;
type PostID = Brand<string, "PostId">;
위 코드는
UserID
와PostID
라는 두 개의 새로운 타입을 만들어낸다.__brand
속성으로 고유성을 부여하여 타입을 더욱 세밀하게 구분할 수 있다.
이제
UserID
와PostID
는 단순한string
이 아닌 고유한 타입이 된다. 이를 활용하면 잘못된 데이터가 전달될 가능성을 줄일 수 있다.
type UserID = Brand<string, "UserId">;
type PostID = Brand<string, "PostId">;
type User = {
id: UserID;
name: string;
}
type Post = {
id: PostID;
ownerId: UserID;
comments: Comment[];
}
async function getCommentsForPost(postId: PostID, authorId: UserID) {
const response = await api.get(`/author/${authorId}/posts/${postId}/comments`);
return response.data;
}
const comments = await getCommentsForPost(user.id, post.id); // ❌ 타입 오류 발생
이제
user.id
와post.id
가 각각UserID
와PostID
타입을 가지므로, 잘못된 인자를 전달할 경우 타입스크립트가 이를 감지할 수 있다.
이로써 컴파일 타임에 잘못된 타입 사용을 미리 방지할 수 있다.
위 방법은 간단하고 유용하지만 몇 가지 문제점이 있다:
1.__brand
속성은 빌드타임에만 존재하며, 런타임에는 사라진다.
2.__brand
속성이 코드 자동 완성(IntelliSense)에 노출되어, 개발자가 오용할 수 있다.
3. 단순한 문자열 기반 태깅은 안전하지 않으며, 중복된 타입을 만들 수 있다.
1.
__brand
속성은 빌드타임에만 존재하며, 런타임에는 사라진다.
- 타입스크립트는 컴파일 타임에만 타입을 체크하고, 그 결과물을 런타임에 사용할 자바스크립트 코드로 변환한다.
type Brand<K, T> = K & { __brand: T }
같은 브랜디드 타입은 타입스크립트가 컴파일 중에만 확인하고, 실제로 자바스크립트 코드로 변환된 후에는 사라진다. 즉, 런타임에는__brand
속성이 존재하지 않게 된다.
왜 문제가 될까?
- 브랜디드 타입은 런타임에 타입의 고유성을 보장하는 것이 아니라, 컴파일 시점에만 타입 검사를 위해 존재한다. 따라서 컴파일을 거친 이후 실제 실행 중에는
__brand
가 사라지기 때문에, 런타임에 이 타입을 검사할 수 없다.- 즉, 타입 안정성을 보장하기 위해 브랜디드 타입을 사용하지만, 런타임에는 사라져버리기 때문에 컴파일 타임에서만 안전성을 확보할 수 있다는 한계가 있다.
2.
__brand
속성이 코드 자동 완성(IntelliSense)에 노출되어, 개발자가 오용할 수 있다.
- IntelliSense는 개발자가 코드 작성 시 자동 완성 기능을 제공하는 툴.
__brand
라는 속성은 브랜디드 타입을 만들기 위해 우리가 태그로 사용한 속성이다.
하지만 이 속성은 개발자가 직접 조작할 의도로 만든 것이 아니다.- 문제는
__brand
가 자동 완성 기능(IntelliSense)에 노출된다는 것이다.
개발자가 코드 작성 중에 이 속성을 볼 수 있고, 오용할 가능성이 생긴다.
예를 들어:
type UserId = Brand<string, "UserId">;
const userId: UserId = "12345" as UserId;
// IntelliSense 자동 완성 기능에서 `userId.__brand`를 볼 수 있게 된다.
- 자동 완성 기능에서
__brand
속성이 노출되면, 개발자가 의도치 않게__brand
속성을 참조하거나 조작할 수 있다. 이는 타입 안전성을 깨뜨릴 수 있는 잠재적인 문제이다.
3. 단순한 문자열 기반 태깅은 안전하지 않으며, 중복된 타입을 만들 수 있다.
type Brand<K, T> = K & { __brand: T }
에서T
는 보통"UserId"
나"Email"
같은 문자열로 사용된다.
그러나 문자열 기반으로 태그를 붙이는 것은 안전하지 않을 수 있다.
왜 문제가 될까?
- 동일한 문자열 값을 사용하여 다른 브랜디드 타입을 만들게 되면 타입의 고유성이 사라지게 된다. 즉, 중복된 브랜디드 타입을 생성할 위험이 있다.
예시:
type UserId = Brand<string, "Id">;
type ProductId = Brand<number, "Id">;
const userId: UserId = "12345" as UserId;
const productId: ProductId = 67890 as ProductId;
- 위 예시에서
"Id"
라는 동일한 문자열을 다른 두 타입 (UserId
,ProductId
)에 태그로 사용했다. 이 경우, 타입스크립트는UserId
와ProductId
를 서로 구분하지 못할 수 있고, 의도치 않게 두 타입이 혼동될 수 있다.- 따라서 단순히 문자열로 태깅하는 것만으로는 완벽한 안전성을 보장할 수 없다는 한계가 있다.
이 문제를 해결하기 위해
unique symbol
을 사용한 개선된 브랜디드 타입 구현이 필요하다.
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
export type Branded<T, B> = T & Brand<B>;
이제
__brand
를unique symbol
로 선언함으로써 고유성을 보장한다. 이를 통해 더 이상__brand
속성이 외부에 노출되지 않고, 같은 이름의 브랜드라도 고유한 심볼을 가지기 때문에 중복될 가능성이 없다.
1. 명확성
브랜디드 타입을 사용하면 변수의 의도를 더 명확하게 표현할 수 있다. 예를 들어,
UserID
와PostID
가 단순한 문자열이 아닌 고유한 타입으로 정의되어 각 용도의 혼동을 방지할 수 있다.
2. 안전성과 정확성
타입 간의 불일치를 더 쉽게 감지하고 런타임 오류를 줄일 수 있다. 잘못된 데이터가 전달될 경우 컴파일 타임에 에러를 발생시킨다.
3. 유지보수성
팀원들이 코드의 의도를 쉽게 이해할 수 있다. => 이게 정말 중요한 것 같다.
1. 커스텀 유효성 검사
브랜디드 타입을 사용해 이메일 주소와 같은 사용자 입력을 검증할 수 있다.
type EmailAddress = Brand<string, "EmailAddress">;
function validEmail(email: string): EmailAddress {
// 이메일 유효성 검사 로직
return email as EmailAddress;
}
2. 도메인 모델링
특정 도메인을 구체적으로 표현할 때 유용하다.
예를 들어, 자동차 제조 라인에서 다양한 속성을 안전하게 관리할 수 있다.
type CarBrand = Brand<string, "CarBrand">;
type EngineType = Brand<string, "EngineType">;
3. API 응답 및 요청
API 호출에서 성공 및 실패 응답을 구분하여 타입 안정성을 높일 수 있다.
type ApiSuccess<T> = T & { __apiSuccessBrand: true };
type ApiFailure = {
code: number;
message: string;
error: Error;
} & { __apiFailureBrand: true };
브랜디드 타입은 타입스크립트의 타입 시스템을 한층 강화하여 코드의 안전성과 명확성을 높여주는 강력한 도구.
- 데이터의 형태와 의도를 더욱 구체적으로 표현
- 컴파일 타임에 잘못된 사용을 감지