Branded Types — 같은 string이지만 다른 의미를 구분하기
Branded Type은 구조적으로 같은 타입에 고유한 브랜드를 붙여 서로 다른 의미로 구분하는 패턴입니다. 명목적 타이핑을 TypeScript에서 흉내내는 방법입니다.
문제: 구조적 타이핑의 한계
type UserId = number;
type ProductId = number;
function getUser(id: UserId) { /* ... */ }
const productId: ProductId = 42;
getUser(productId); // ⚠️ 에러 없음! — 둘 다 number이므로 호환됨
UserId와 ProductId가 모두 number이므로 TypeScript는 이를 구분하지 못합니다. 이는 심각한 논리 오류를 유발할 수 있습니다.
해결: Branded Type
// 브랜드 속성을 인터섹션으로 추가
type UserId = number & { readonly __brand: unique symbol };
type ProductId = number & { readonly __brand: unique symbol };
// 값을 생성하는 팩토리 함수
function createUserId(id: number): UserId {
return id as UserId;
}
function createProductId(id: number): ProductId {
return id as ProductId;
}
// 이제 서로 호환되지 않음
function getUser(id: UserId) { /* ... */ }
const userId = createUserId(1);
const productId = createProductId(42);
getUser(userId); // ✅ OK
// getUser(productId); // ❌ Error — ProductId는 UserId에 할당 불가
// getUser(42); // ❌ Error — 일반 number도 할당 불가
범용 Brand 유틸리티
// 범용 브랜딩 유틸리티
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<number, 'UserId'>;
type ProductId = Brand<number, 'ProductId'>;
type Email = Brand<string, 'Email'>;
type Url = Brand<string, 'Url'>;
검증과 결합
Branded Type의 진가는 생성 시 검증 과 결합할 때 나옵니다.
type Email = Brand<string, 'Email'>;
type PositiveNumber = Brand<number, 'PositiveNumber'>;
// 검증 후 브랜드를 붙여서 반환
function createEmail(input: string): Email {
if (!input.includes('@')) {
throw new Error('유효하지 않은 이메일');
}
return input as Email;
}
function createPositiveNumber(n: number): PositiveNumber {
if (n <= 0) {
throw new Error('양수여야 합니다');
}
return n as PositiveNumber;
}
// 안전한 사용
function sendEmail(to: Email, subject: string) {
// to는 이미 검증된 이메일
console.log(`${to}에게 메일 발송: ${subject}`);
}
const email = createEmail('test@example.com');
sendEmail(email, '안녕하세요'); // OK
// sendEmail('not-email', '안녕'); // ❌ Error
Result 패턴과 결합
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parseEmail(input: string): Result<Email, string> {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(input)) {
return { ok: false, error: '유효하지 않은 이메일 형식' };
}
return { ok: true, value: input as Email };
}
Zod와 결합
import { z } from 'zod';
const EmailSchema = z.string().email().brand<'Email'>();
type Email = z.infer<typeof EmailSchema>; // string & Brand<'Email'>
const result = EmailSchema.safeParse('test@example.com');
if (result.success) {
// result.data는 Email 타입
}
Zod의 .brand() 메서드로 스키마 검증과 브랜딩을 한 번에 할 수 있습니다.
실전 활용
화폐 단위 구분
type KRW = Brand<number, 'KRW'>;
type USD = Brand<number, 'USD'>;
function krw(amount: number): KRW { return amount as KRW; }
function usd(amount: number): USD { return amount as USD; }
function addKRW(a: KRW, b: KRW): KRW {
return (a + b) as KRW;
}
const price = krw(10000);
const tax = krw(1000);
addKRW(price, tax); // ✅ OK
const dollars = usd(100);
// addKRW(price, dollars); // ❌ Error — 달러와 원화를 섞을 수 없음
정렬된 배열
type SortedArray<T> = readonly T[] & { readonly __brand: 'Sorted' };
function sort<T>(arr: readonly T[], compare: (a: T, b: T) => number): SortedArray<T> {
return [...arr].sort(compare) as unknown as SortedArray<T>;
}
function binarySearch<T>(arr: SortedArray<T>, target: T): number {
// 정렬된 배열에서만 동작하는 함수
// ...
return -1;
}
const sorted = sort([3, 1, 2], (a, b) => a - b);
binarySearch(sorted, 2); // ✅ OK
// binarySearch([3, 1, 2], 2); // ❌ Error — 정렬되지 않은 배열
주의점
// 브랜드 속성은 런타임에 존재하지 않음
const id = createUserId(42);
console.log(typeof id); // 'number' — 여전히 number
console.log(id + 1); // 43 — 산술 연산 가능
// as로 브랜드를 우회할 수 있음 (개발자의 규율에 의존)
const fake = 42 as UserId; // ⚠️ 검증 없이 브랜드를 붙임
정리
- Branded Type은 구조적 타이핑의 한계를 보완하는 패턴이다
T & { __brand: 'Name' }형태로 고유한 타입을 만든다- 팩토리 함수에서 검증 후 브랜드를 붙이면 "검증된 값"을 타입으로 보장한다
- Zod의
.brand()와 결합하면 더 편리하다 - 브랜드는 컴파일 타임에만 존재하며 런타임에는 영향이 없다
댓글 로딩 중...