TypeScript Template Literal Types와 Branded Types: 런타임 없이 도메인 오류를 타입으로 막는 설계

타입이 런타임 오류를 막지 못한다면, 타입 시스템은 절반짜리다
우리 TypeScript 개발자들은 종종 착각에 빠집니다. "타입을 붙였으니 안전하다"는 믿음입니다. 그러나 string 타입의 userId와 string 타입의 orderId는 TypeScript 컴파일러 눈에 동일하게 보입니다.
우리 팀이 결제 서비스와 배송 서비스를 통합하는 프로젝트를 담당했을 때 정확히 이 상황을 겪었습니다. 두 서비스 모두 string 타입의 식별자를 주고받는 API였고, 팀원이 processShipment(userId, orderId) 함수 호출에서 인자 순서를 뒤바꾼 버그는 스테이징 환경의 E2E 테스트를 통과한 뒤 프로덕션에서 터졌습니다.
Template Literal Types와 Branded Types는 이 문제를 런타임 페널티 없이, 타입 레이어에서만 해결하는 두 가지 핵심 도구입니다.
1. Template Literal Types 기본
TypeScript 4.1에 도입된 Template Literal Types는 JavaScript의 템플릿 리터럴 문법을 타입 레이어로 그대로 가져온 기능입니다. 공식 핸드북에 따르면, 이 기능은 문자열 유니온 타입과 결합되었을 때 기하급수적인 타입 조합을 자동 생성하는 강력함을 발휘합니다.
type EventName = `on${string}`;
const onClick: EventName = 'onClick';
const rawString: EventName = 'click'; // Error
type Locale = 'ko' | 'en' | 'ja';
type Direction = 'Left' | 'Right' | 'Top' | 'Bottom';
type LocalizedPadding = `${Locale}-padding-${Direction}`;
type UserId = `usr_${string}`;
type OrderId = `ord_${string}`;
function getUser(id: UserId) {
return fetch(`/api/users/${id}`);
}
getUser('usr_abc123'); // OK
getUser('ord_xyz789'); // Error
UserId와 OrderId는 구조적으로 모두 string이지만, Template Literal Types 덕분에 서로 다른 타입으로 취급됩니다. 런타임에는 여전히 일반 string입니다.
2. 문자열 유니온 자동 생성
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products' | 'payments';
type ApiRoute = `/${ApiVersion}/${Resource}`;
type UserEvent = `user:${'created' | 'updated' | 'deleted' | 'suspended'}`;
type OrderEvent = `order:${'placed' | 'paid' | 'shipped' | 'delivered' | 'cancelled'}`;
type DomainEvent = UserEvent | OrderEvent;
function emit(event: DomainEvent, payload: unknown) {
console.log(`[Event] ${event}`, payload);
}
emit('user:created', { id: 'usr_001' }); // OK
emit('user:shipped', {}); // Error
유니온의 멤버 수가 많아질수록 TypeScript 컴파일러의 체크 비용이 증가합니다. Uppercase<T>, Lowercase<T>, Capitalize<T> 같은 내장 유틸리티와 결합하면 API의 camelCase 키를 snake_case로 변환하는 타입 변환 계층을 설계할 수 있습니다.
3. Infer로 경로 파싱
infer 키워드와 Template Literal Types를 결합하면 문자열 패턴에서 일부를 파싱하는 타입을 만들 수 있습니다.
type ExtractRouteParams<Route extends string> =
Route extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractRouteParams<Rest>]: string }
: Route extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
type Params2 = ExtractRouteParams<'/users/:userId/orders/:orderId'>;
// 결과: { userId: string; orderId: string }
function createHandler<Route extends string>(
route: Route,
handler: (params: ExtractRouteParams<Route>, req: Request) => Response
) {
return { route, handler };
}
const userOrderHandler = createHandler(
'/users/:userId/orders/:orderId',
(params, req) => {
const { userId, orderId } = params;
return new Response(`User ${userId}, Order ${orderId}`);
}
);
이 패턴은 TypeScript GitHub 이슈 #4895에서 명목적(Nominal) 타이핑을 요청한 커뮤니티 논의와 맞닿아 있습니다.
4. Branded(Nominal) Types 개념
TypeScript는 구조적 타이핑(Structural Typing) 언어입니다. 두 타입의 구조가 호환되면, 이름이 달라도 같은 타입으로 취급합니다.
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function makeUserId(raw: string): UserId {
return raw as UserId;
}
function getOrder(userId: UserId, orderId: OrderId) {
return fetch(`/api/users/${userId}/orders/${orderId}`);
}
const uid = makeUserId('usr_001');
const oid = makeOrderId('ord_abc');
getOrder(uid, oid); // OK
getOrder(oid, uid); // Error
__brand 프로퍼티는 declare const로 선언된 unique symbol이므로 실제로는 존재하지 않습니다. 런타임에는 순수한 string입니다.
5. 브랜딩으로 UserId vs OrderId 혼동 방지
Branded Types를 프로젝트에 본격 도입할 때 우리 팀이 따른 설계 원칙은 세 가지입니다.
첫째, 생성 함수는 유효성 검증 책임을 가집니다.
둘째, Branded Type은 경계를 통과할 때만 해제합니다.
셋째, 하나의 as 캐스팅은 하나의 팩토리 함수 안에만 있습니다.
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
function parseUserId(raw: unknown): UserId {
if (typeof raw !== 'string' || !raw.startsWith('usr_') || raw.length < 6) {
throw new Error(`Invalid UserId format: ${String(raw)}`);
}
return raw as UserId;
}
async function fetchOrderFromAPI(rawUserId: string, rawOrderId: string) {
const userId = parseUserId(rawUserId);
const orderId = parseOrderId(rawOrderId);
return orderService.getOrder(userId, orderId);
}
function processShipment(userId: UserId, orderId: OrderId) { /* ... */ }
processShipment(oid, uid); // Error
6. 퍼포먼스 영향과 컴파일 시간
| 기법 | 런타임 오버헤드 | 컴파일 타임 비용 | 코드 가독성 |
|---|---|---|---|
단순 string 타입 | 없음 | 최소 | 낮음 |
| Template Literal Type | 없음 | 유니온 크기의 제곱에 비례 | 중간 |
| Branded Type (phantom) | 없음 | 매우 낮음 | 높음 |
| class 래퍼 객체 | 객체 생성 비용 | 낮음 | 높음 |
Template Literal Types에서 주의해야 할 점은 유니온 폭발입니다. TypeScript 팀은 단일 유니온이 100,000개를 넘으면 경고를 발생시키도록 제한을 두고 있습니다.
7. Zod와 결합한 런타임 브랜딩
Zod의 .brand() 메서드(공식 문서)는 스키마 파싱 결과에 Branded Type을 자동으로 부여합니다.
import { z } from 'zod';
const UserIdSchema = z
.string()
.startsWith('usr_')
.min(6)
.brand<'UserId'>();
const OrderIdSchema = z
.string()
.startsWith('ord_')
.min(6)
.brand<'OrderId'>();
type UserId = z.infer<typeof UserIdSchema>;
type OrderId = z.infer<typeof OrderIdSchema>;
const CreateOrderResponseSchema = z.object({
orderId: OrderIdSchema,
userId: UserIdSchema,
status: z.enum(['pending', 'paid', 'failed']),
});
async function createOrder(userId: UserId): Promise<z.infer<typeof CreateOrderResponseSchema>> {
const raw = await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify({ userId }),
}).then(r => r.json());
return CreateOrderResponseSchema.parse(raw);
}
타입 시스템의 더 넓은 맥락은 TypeScript 고급 타입 시스템 총정리에서 다루고 있습니다.
8. opaque 타입 라이브러리: ts-brand 도입 가이드
ts-brand는 TypeScript 커뮤니티에서 가장 널리 사용되는 Branded Type 헬퍼 라이브러리입니다.
import { Brand, make } from 'ts-brand';
type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;
const makeUserId = make<UserId>();
const makeOrderId = make<OrderId>();
const uid = makeUserId('usr_001');
const oid = makeOrderId('ord_abc');
function getOrder(userId: UserId, orderId: OrderId) {
return fetch(`/api/users/${userId}/orders/${orderId}`);
}
getOrder(uid, oid); // OK
getOrder(oid, uid); // Error
9. 실무 적용 사례
레거시 코드베이스에서 결제 도메인의 모든 식별자가 string으로 선언되어 있었고, userId, orderId, paymentId, invoiceId, merchantId가 함수 간에 자유롭게 섞여 전달되는 상황이었습니다.
전환 작업은 세 단계로 나뉘었습니다.
1단계: Branded Type 인프라 구축. src/types/branded.ts 파일 하나에 모든 도메인 식별자 타입과 팩토리 함수를 정의했습니다.
2단계: 경계면부터 교체. API 응답을 파싱하는 레이어부터 Zod 스키마 + .brand()를 적용했습니다.
3단계: 내부 함수 시그니처 교체. 컴파일 에러가 발생하는 함수들의 파라미터 타입을 순서대로 교체했습니다.
전환 후 결과: 코드 리뷰에서 "이 파라미터 순서 맞나요?"라는 주석이 완전히 사라졌고, 식별자 혼용으로 인한 런타임 오류 보고가 관련 도메인에서 발생하지 않고 있습니다. TypeScript 오류 처리에 대한 패턴은 Result 패턴으로 TypeScript 에러 처리하기에서 이어집니다.
10. 안티패턴 정리
안티패턴 1: 내부에서 as 캐스팅 남용.
안티패턴 2: 모든 string에 Branded Type 적용.
안티패턴 3: Template Literal Types에서 유니온 무한 확장.
안티패턴 4: 브랜딩과 유효성 검증 분리.
// 위험한 패턴: 검증 없이 브랜딩
function asUserId(raw: string): UserId {
return raw as UserId;
}
// 올바른 패턴: 검증 후 브랜딩
function parseUserId(raw: string): UserId {
if (!raw.startsWith('usr_') || raw.length < 10) {
throw new TypeError(`"${raw}"는 유효한 UserId 형식이 아닙니다.`);
}
return raw as UserId;
}
결론
- 경계면 식별: 외부 데이터가 Branded Type으로 승격되는 지점이 명확하게 정해져 있는가?
- 캐스팅 집중:
as UserId같은 직접 타입 단언이 팩토리 함수 본문에만 존재하는가? - 유니온 크기 관리: Template Literal Types로 생성되는 유니온의 멤버 수가 수백 개를 넘지 않는가?
- 적용 범위 선택: Branded Type을 적용한 대상이 실제로 도메인 의미 혼용의 위험이 있는 식별자인가?
- 팩토리 함수 내 검증: 브랜딩 팩토리 함수가 단순 캐스팅이 아닌 도메인 규칙 검증을 포함하고 있는가?
타입 시스템이 강제하는 규칙은 코드 리뷰 없이도, 테스트 없이도, 24시간 작동합니다.