Branded Type은 구조적으로 같은 타입에 고유한 브랜드를 붙여 서로 다른 의미로 구분하는 패턴입니다. 명목적 타이핑을 TypeScript에서 흉내내는 방법입니다.

문제: 구조적 타이핑의 한계

TYPESCRIPT
type UserId = number;
type ProductId = number;

function getUser(id: UserId) { /* ... */ }

const productId: ProductId = 42;
getUser(productId); // ⚠️ 에러 없음! — 둘 다 number이므로 호환됨

UserIdProductId가 모두 number이므로 TypeScript는 이를 구분하지 못합니다. 이는 심각한 논리 오류를 유발할 수 있습니다.

해결: Branded Type

TYPESCRIPT
// 브랜드 속성을 인터섹션으로 추가
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 유틸리티

TYPESCRIPT
// 범용 브랜딩 유틸리티
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의 진가는 생성 시 검증 과 결합할 때 나옵니다.

TYPESCRIPT
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 패턴과 결합

TYPESCRIPT
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와 결합

TYPESCRIPT
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() 메서드로 스키마 검증과 브랜딩을 한 번에 할 수 있습니다.

실전 활용

화폐 단위 구분

TYPESCRIPT
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 — 달러와 원화를 섞을 수 없음

정렬된 배열

TYPESCRIPT
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 — 정렬되지 않은 배열

주의점

TYPESCRIPT
// 브랜드 속성은 런타임에 존재하지 않음
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()와 결합하면 더 편리하다
  • 브랜드는 컴파일 타임에만 존재하며 런타임에는 영향이 없다
댓글 로딩 중...