iBetter Books
수정

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 등 무엇이든 괜찮지만, 한 유니온 안에서 일관되게 사용해야 합니다.