Ch 02. 판별 유니온
typeof와 instanceof로 원시 타입과 클래스는 구분할 수 있습니다. 그런데 일반 객체 여러 개를 유니온으로 묶었을 때는 어떻게 구분할까요. 판별 유니온(discriminated union)이 이 문제를 해결합니다.
판별 유니온의 구조
판별 유니온은 유니온 안의 모든 타입이 동일한 이름의 리터럴 타입 필드를 가지는 패턴입니다. 그 공통 필드를 판별자(discriminant)라고 부릅니다.
// 파일: src/unions/shape.tsinterface Circle { kind: "circle"; // 판별자 radius: number;}interface Rectangle { kind: "rectangle"; // 판별자 width: number; height: number;}interface Triangle { kind: "triangle"; // 판별자 base: number; height: number;}type Shape = Circle | Rectangle | Triangle;
kind 필드가 리터럴 타입("circle", "rectangle", "triangle")이라는 점이 핵심입니다. 이제 kind를 확인하면 TypeScript가 해당 블록에서 구체적인 타입으로 좁혀줍니다.
// 파일: src/unions/shape.ts (계속)function getArea(shape: Shape): number { switch (shape.kind) { case "circle": // shape은 Circle — radius에 접근 가능 return Math.PI * shape.radius ** 2; case "rectangle": // shape은 Rectangle — width, height에 접근 가능 return shape.width * shape.height; case "triangle": // shape은 Triangle return (shape.base * shape.height) / 2; }}
if-else 체인으로도 동작합니다
switch만 동작하는 것이 아닙니다. if-else도 완전히 동일하게 작동합니다.
// 파일: src/unions/result.tstype Result<T> = | { status: "success"; data: T } | { status: "error"; message: string; code: number } | { status: "loading" };function handleResult<T>(result: Result<T>): string { if (result.status === "success") { // { status: "success"; data: T } return `완료: ${JSON.stringify(result.data)}`; } if (result.status === "error") { // { status: "error"; message: string; code: number } return `오류 [${result.code}]: ${result.message}`; } // { status: "loading" } return "로딩 중...";}
실전 패턴: 이벤트 시스템
UI 이벤트, 메시지 큐, 상태 머신 등에서 판별 유니온이 매우 자주 쓰입니다.
// 파일: src/unions/events.tstype AppEvent = | { type: "USER_LOGIN"; userId: string; timestamp: number } | { type: "USER_LOGOUT"; userId: string } | { type: "ITEM_ADDED"; itemId: string; quantity: number } | { type: "ITEM_REMOVED"; itemId: string } | { type: "ORDER_PLACED"; orderId: string; total: number };function logEvent(event: AppEvent): void { switch (event.type) { case "USER_LOGIN": console.log(`로그인: ${event.userId} at ${event.timestamp}`); break; case "USER_LOGOUT": console.log(`로그아웃: ${event.userId}`); break; case "ITEM_ADDED": console.log(`상품 추가: ${event.itemId} x${event.quantity}`); break; case "ITEM_REMOVED": console.log(`상품 제거: ${event.itemId}`); break; case "ORDER_PLACED": console.log(`주문 완료: ${event.orderId}, 합계 ${event.total}원`); break; }}
새 이벤트 타입을 AppEvent에 추가하면 이 함수에서 처리하지 않은 케이스가 생깁니다. 이 누락을 컴파일 타임에 잡으려면 Ch 04의 never를 활용한 완전성 검사와 함께 사용합니다.
공통 필드와 고유 필드 설계
판별 유니온을 잘 설계하려면 공통 필드와 고유 필드를 명확히 분리해야 합니다.
// 파일: src/unions/notification.ts// 모든 알림이 공통으로 갖는 필드interface BaseNotification { id: string; createdAt: Date; read: boolean;}type Notification = | (BaseNotification & { type: "email"; subject: string; body: string; }) | (BaseNotification & { type: "sms"; phoneNumber: string; message: string; }) | (BaseNotification & { type: "push"; title: string; body: string; url?: string; });function sendNotification(notification: Notification): void { // 공통 필드는 어디서든 접근 가능 console.log(`알림 ID: ${notification.id}`); switch (notification.type) { case "email": console.log(`이메일 전송: ${notification.subject}`); break; case "sms": console.log(`SMS 전송 to ${notification.phoneNumber}`); break; case "push": console.log(`푸시 알림: ${notification.title}`); break; }}
패턴 매칭 스타일로 작성하기
TypeScript에는 언어 수준의 패턴 매칭이 없지만, 판별 유니온과 switch를 조합하면 유사한 효과를 낼 수 있습니다. 아래는 dispatch 함수 패턴입니다.
// 파일: src/unions/dispatch.tstype Action = | { type: "INCREMENT"; by: number } | { type: "DECREMENT"; by: number } | { type: "RESET" };interface State { count: number;}function reducer(state: State, action: Action): State { switch (action.type) { case "INCREMENT": return { count: state.count + action.by }; case "DECREMENT": return { count: state.count - action.by }; case "RESET": return { count: 0 }; }}// 사용let state: State = { count: 0 };state = reducer(state, { type: "INCREMENT", by: 5 }); // { count: 5 }state = reducer(state, { type: "DECREMENT", by: 2 }); // { count: 3 }state = reducer(state, { type: "RESET" }); // { count: 0 }
이 패턴은 Redux와 useReducer의 근간이기도 합니다. TypeScript를 쓰면 잘못된 action 타입이나 없는 필드 접근을 컴파일 타임에 모두 잡을 수 있습니다.
판별 유니온은 객체 유니온을 다루는 가장 관용적인 TypeScript 패턴입니다. 판별자 필드 이름은 type, kind, tag 등 무엇이든 괜찮지만, 한 유니온 안에서 일관되게 사용해야 합니다.