TypeScript Branded Types로 타입 안전성 높이기

모든 ID가 string 타입이라 인자 순서를 바꿔도 컴파일 에러가 안 났습니다. Branded Types 패턴으로 런타임 오버헤드 없이 타입 안전성을 확보한 방법을 공유합니다.

TypeScript Branded Types로 타입 안전성 높이기

1. 문제 상황

1.1 타입 안전성의 함정

TypeScript를 사용하면 타입 안전성이 보장된다고 생각하기 쉽습니다. 하지만 다음 코드에서 버그를 발견할 수 있나요?

// API 호출 함수들
async function getItem(itemId: string) {
  return fetch(`/api/items/${itemId}`);
}

async function getGroup(groupId: string) {
  return fetch(`/api/groups/${groupId}`);
}

// 사용 코드
const itemId = "clx1234567890";   // 항목 ID
const groupId = "cly0987654321";  // 그룹 ID

// 버그! itemId를 groupId 위치에 사용
const group = await getGroup(itemId);  // 타입 에러 없음!

문제: itemIdgroupId 모두 string 타입이라 TypeScript가 실수를 잡지 못합니다.

1.2 실제 발생한 버그

// 기록 삭제 API 호출
async function deleteRecord(recordId: string, itemId: string) {
  return fetch(`/api/records/${recordId}`, {
    method: 'DELETE',
    body: JSON.stringify({ itemId }),
  });
}

// 사용 코드
const { recordId, itemId } = selectedCell;

// 버그! 인자 순서를 바꿔서 호출
await deleteRecord(itemId, recordId);  // 컴파일 통과!

결과: API가 잘못된 ID로 호출되어 404 에러 또는 엉뚱한 데이터 삭제

1.3 왜 발생하는가?

문제 원인
구조적 타이핑 TypeScript는 구조가 같으면 같은 타입으로 취급
string 남용 ID, 날짜, 시간 등 모든 것을 string으로 표현
런타임 발견 버그가 테스트/프로덕션에서만 발견됨

2. 해결 방법: 브랜디드 타입

2.1 브랜디드 타입이란?

**브랜디드 타입(Branded Types)**은 구조적으로 동일한 타입에 "브랜드(태그)"를 추가하여 구분하는 패턴입니다.

기존: string === string (구분 불가)
브랜디드: Brand<string, "ItemId"> !== Brand<string, "GroupId"> (구분 가능)

핵심 원리:

  • 컴파일 타임에만 존재하는 가상의 속성 추가
  • 런타임에는 일반 string과 동일하게 동작 (오버헤드 없음)
  • TypeScript 컴파일러가 서로 다른 브랜드를 다른 타입으로 인식

2.2 기본 구현

// 브랜드 심볼 (컴파일 타임에만 존재)
declare const __brand: unique symbol;

// 브랜디드 타입 유틸리티
type Brand<T, B extends string> = T & { readonly [__brand]: B };

동작 원리:

Brand<string, "ItemId">
  = string & { readonly [__brand]: "ItemId" }

Brand<string, "GroupId">
  = string & { readonly [__brand]: "GroupId" }

→ __brand 속성의 값이 다르므로 서로 다른 타입!

2.3 ID 타입 정의

// types/branded.ts

declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };

// ID 타입들
export type ItemId = Brand<string, "ItemId">;
export type GroupId = Brand<string, "GroupId">;
export type UserId = Brand<string, "UserId">;
export type RecordId = Brand<string, "RecordId">;

// 날짜/시간 타입들
export type DateString = Brand<string, "DateString">;   // YYYY-MM-DD
export type TimeString = Brand<string, "TimeString">;   // HH:MM

3. 타입 캐스팅과 검증

3.1 캐스팅 헬퍼 함수

브랜디드 타입을 사용하려면 일반 string을 캐스팅해야 합니다:

// 단순 캐스팅 (런타임 검증 없음)
export const asItemId = (id: string): ItemId => id as ItemId;
export const asGroupId = (id: string): GroupId => id as GroupId;
export const asUserId = (id: string): UserId => id as UserId;
export const asRecordId = (id: string): RecordId => id as RecordId;

사용 예시:

// 데이터베이스에서 가져온 ID (신뢰할 수 있는 소스)
const itemId = asItemId(item.id);
const groupId = asGroupId(group.id);

// 타입 에러!
const wrong: GroupId = itemId;
// Error: Type 'ItemId' is not assignable to type 'GroupId'

3.2 검증 함수 (날짜/시간용)

형식 검증이 필요한 타입에는 검증 함수를 제공합니다:

/** DateString 형식 검증 (YYYY-MM-DD) */
export function isValidDateString(value: string): value is DateString {
  return /^\d{4}-\d{2}-\d{2}$/.test(value);
}

/** TimeString 형식 검증 (HH:MM, 00:00~23:59) */
export function isValidTimeString(value: string): value is TimeString {
  return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
}

/**
 * 문자열을 DateString으로 변환 (검증 포함)
 * @throws 형식이 올바르지 않으면 에러
 */
export function toDateString(value: string): DateString {
  if (!isValidDateString(value)) {
    throw new Error(`Invalid date format: ${value}. Expected YYYY-MM-DD`);
  }
  return value;
}

/**
 * 문자열을 TimeString으로 변환 (검증 포함)
 * @throws 형식이 올바르지 않으면 에러
 */
export function toTimeString(value: string): TimeString {
  if (!isValidTimeString(value)) {
    throw new Error(`Invalid time format: ${value}. Expected HH:MM`);
  }
  return value;
}

3.3 Type Guard 활용

// 타입 가드로 안전하게 변환
function processDate(input: string) {
  if (isValidDateString(input)) {
    // input은 이제 DateString 타입
    saveRecord(input);  // 타입 안전!
  } else {
    throw new Error('Invalid date format');
  }
}

// 또는 toDateString으로 변환 (에러 발생 가능)
function processDateUnsafe(input: string) {
  const date = toDateString(input);  // 잘못된 형식이면 에러
  saveRecord(date);
}

4. 실제 적용 사례

4.1 기록 타입 정의

// types/record.ts
import type { DateString, TimeString } from './branded';

export interface Record {
  id: string;
  itemId: string;
  date: DateString;              // 브랜디드 타입!
  statusType: StatusType;
  startTime: TimeString | null;  // 브랜디드 타입!
  endTime: TimeString | null;    // 브랜디드 타입!
  breakStart: TimeString | null;
  breakEnd: TimeString | null;
  note: string | null;
}

export interface RecordCell {
  itemId: string;
  date: DateString;              // 브랜디드 타입!
  statusType: StatusType | null;
  hasNote: boolean;
  hasTimeDetails: boolean;
  recordId?: string;
}

export interface RecordFormData {
  itemId: string;
  date: DateString;              // 브랜디드 타입!
  statusType: StatusType;
  startTime?: TimeString;        // 브랜디드 타입!
  endTime?: TimeString;
  breakStart?: TimeString;
  breakEnd?: TimeString;
  note?: string;
}

4.2 API 응답 처리

// API에서 받은 데이터 변환
const recordMap: Record<string, Record<string, RecordCell>> = {};

itemsWithRecords.forEach((item) => {
  recordMap[item.id] = {};

  item.records.forEach((rec) => {
    // formatLocalDate는 DateString을 반환
    const dateStr = formatLocalDate(rec.date) as DateString;

    recordMap[item.id][dateStr] = {
      itemId: item.id,
      date: dateStr,  // DateString 타입
      statusType: rec.statusType,
      hasNote: !!rec.note,
      hasTimeDetails: !!(rec.startTime || rec.endTime),
      recordId: rec.id,
    };
  });
});

4.3 낙관적 업데이트에서 사용

// hooks/useOptimisticData.ts
import type { DateString } from '@/types/branded';

const optimisticDelete = useCallback(
  async (
    itemId: string,
    date: DateString,        // 브랜디드 타입으로 타입 안전성 확보
    recordId: string
  ): Promise<boolean> => {
    // ...
  },
  [/* deps */]
);

const optimisticQuickInput = useCallback(
  async (
    itemId: string,
    date: DateString,        // 일반 string이 아닌 DateString
    statusType: StatusType | null,
    recordId?: string
  ): Promise<boolean> => {
    // ...
  },
  [/* deps */]
);

5. Before/After 비교

5.1 함수 시그니처

// ❌ Before: 모든 것이 string
async function deleteRecord(
  recordId: string,
  itemId: string,
  date: string
)

// ✅ After: 브랜디드 타입으로 구분
async function deleteRecord(
  recordId: RecordId,
  itemId: ItemId,
  date: DateString
)

5.2 잘못된 사용 감지

// ❌ Before: 컴파일 통과 (런타임 버그)
const itemId = "item-123";
const groupId = "group-456";
await getGroup(itemId);  // 버그지만 타입 에러 없음

// ✅ After: 컴파일 에러
const itemId: ItemId = asItemId("item-123");
const groupId: GroupId = asGroupId("group-456");
await getGroup(itemId);
// Error: Argument of type 'ItemId' is not assignable to parameter of type 'GroupId'

5.3 인터페이스 정의

// ❌ Before: 주석으로만 형식 표시
interface Record {
  date: string;       // YYYY-MM-DD (주석에 의존)
  startTime: string;  // HH:MM (주석에 의존)
}

// ✅ After: 타입으로 형식 보장
interface Record {
  date: DateString;       // 타입 자체가 형식을 표현
  startTime: TimeString;  // 컴파일러가 형식을 강제
}

6. 핵심 개념 정리

6.1 런타임 vs 컴파일 타임

구분 런타임 검증 컴파일 타임 검증 (브랜디드 타입)
검증 시점 코드 실행 시 빌드 시
오버헤드 있음 (검증 함수 실행) 없음 (타입만 존재)
버그 발견 테스트/프로덕션에서 개발 중에
에러 유형 런타임 에러 컴파일 에러

6.2 브랜디드 타입 선택 기준

✅ 브랜디드 타입이 적합한 경우:
- ID 타입들 (ItemId, GroupId 등)
- 특정 형식의 문자열 (날짜, 시간, 이메일 등)
- 단위가 있는 숫자 (Meters, Kilograms 등)
- 서로 다른 도메인의 동일한 기본 타입

❌ 브랜디드 타입이 과한 경우:
- 이미 구분되는 타입 (number vs string)
- 한 곳에서만 사용되는 타입
- 외부 라이브러리와 호환이 필요한 타입

6.3 브랜디드 타입 사용 패턴

// 1. 신뢰할 수 있는 소스: as 캐스팅
const id = asItemId(dbResult.id);

// 2. 사용자 입력: 검증 후 캐스팅
if (isValidDateString(userInput)) {
  const date = userInput;  // 타입 가드로 자동 캐스팅
}

// 3. 변환 필요 시: to* 함수 사용
const date = toDateString(formData.date);  // 검증 포함

// 4. 타입 간 변환: 명시적 재캐스팅
function formatDate(date: DateString): string {
  return date;  // 그냥 사용 가능 (string의 상위 타입)
}

7. 베스트 프랙티스

7.1 브랜디드 타입 체크리스트

□ ID 타입들을 브랜디드 타입으로 정의했는가?
□ 캐스팅 헬퍼 함수를 제공하는가?
□ 날짜/시간 등 형식 검증이 필요한 타입에 검증 함수가 있는가?
□ 인터페이스에서 브랜디드 타입을 사용하는가?
□ API 경계에서 올바른 타입을 사용하는가?

7.2 캐스팅 위치

// ✅ Good: 데이터 경계에서 한 번만 캐스팅
function fetchItem(id: string): Promise<Item> {
  return fetch(`/api/items/${id}`).then(r => {
    const data = r.json();
    return {
      ...data,
      id: asItemId(data.id),
      groupId: asGroupId(data.groupId),
    };
  });
}

// ❌ Bad: 매번 캐스팅
function doSomething(id: string) {
  const itemId = asItemId(id);
  // ...
  const sameId = asItemId(id);  // 중복 캐스팅
}

7.3 Zod와 함께 사용

import { z } from 'zod';

// Zod 스키마에서 브랜디드 타입으로 변환
const RecordSchema = z.object({
  itemId: z.string().transform(asItemId),
  date: z.string()
    .regex(/^\d{4}-\d{2}-\d{2}$/)
    .transform(val => val as DateString),
  startTime: z.string()
    .regex(/^([01]\d|2[0-3]):([0-5]\d)$/)
    .optional()
    .nullable()
    .transform(val => val as TimeString | null),
});

// 사용
const result = RecordSchema.parse(rawData);
// result.date는 DateString 타입!

7.4 주의사항

// 1. 런타임에는 일반 string처럼 동작
const id: ItemId = asItemId("item-123");
console.log(typeof id);  // "string" (브랜드는 없어짐)

// 2. JSON 직렬화 시 브랜드 정보 손실
const json = JSON.stringify({ id });
const parsed = JSON.parse(json);
// parsed.id는 string 타입 (ItemId 아님)
// → API 응답 처리 시 재캐스팅 필요

// 3. as 캐스팅은 검증하지 않음
const invalid = asItemId("");  // 빈 문자열도 캐스팅됨
// → 사용자 입력에는 검증 함수 사용

8. 확장 패턴

8.1 숫자 브랜디드 타입

// 단위가 있는 숫자
type Meters = Brand<number, "Meters">;
type Kilograms = Brand<number, "Kilograms">;
type Hours = Brand<number, "Hours">;

const distance: Meters = 100 as Meters;
const weight: Kilograms = 50 as Kilograms;

// 타입 에러: 다른 단위 혼용 방지
const total: Meters = distance + weight;  // Error!

8.2 복합 브랜디드 타입

// 여러 검증 조건을 결합
type NonEmptyString = Brand<string, "NonEmpty">;
type Email = Brand<string, "Email">;
type ValidatedEmail = NonEmptyString & Email;

function isNonEmpty(s: string): s is NonEmptyString {
  return s.length > 0;
}

function isEmail(s: string): s is Email {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}

function validateEmail(s: string): ValidatedEmail | null {
  if (isNonEmpty(s) && isEmail(s)) {
    return s as ValidatedEmail;
  }
  return null;
}

9. 참고 자료


10. 다음 단계

브랜디드 타입으로 컴파일 타임 안전성을 확보했다면, 타임존 안전한 날짜 처리도 적용해보세요.

시리즈 목차:

  1. TypeScript Branded Types로 타입 안전성 높이기 ← 현재 글
  2. JavaScript 타임존 함정 피하기: UTC vs Local 날짜 처리

11. FAQ (자주 묻는 질문)

Q: 브랜디드 타입은 런타임에 오버헤드가 있나요?

A: 아니요. 브랜디드 타입은 컴파일 타임에만 존재하며, 런타임에는 일반 string과 완전히 동일하게 동작합니다. JavaScript로 컴파일되면 타입 정보는 사라집니다.

Q: API 응답에서 브랜디드 타입을 어떻게 사용하나요?

A: API 응답을 받는 경계에서 한 번만 캐스팅하면 됩니다. 예: asItemId(response.id). 이후에는 해당 타입이 전파됩니다.

Q: Prisma나 다른 ORM과 함께 사용할 수 있나요?

A: 네, 가능합니다. DB에서 가져온 데이터를 변환할 때 캐스팅하면 됩니다. 단, 쿼리 작성 시에는 일반 string으로 다시 변환해야 할 수 있습니다.

Q: 기존 프로젝트에 점진적으로 도입할 수 있나요?

A: 가능합니다. 먼저 타입만 정의하고, 새로 작성하는 코드부터 적용하면 됩니다. 기존 코드는 as 캐스팅으로 호환성을 유지할 수 있습니다.