junyeokk
Blog
TypeScript·2025. 11. 15

Discriminated Union

TypeScript에서 여러 타입을 유니온(|)으로 묶으면 "이 값은 A일 수도, B일 수도 있다"는 표현이 가능하다. 그런데 실제 코드에서는 A일 때와 B일 때 각각 다른 처리를 해야 한다. 단순한 유니온만으로는 TypeScript가 지금 이 값이 A인지 B인지 자동으로 구분해주지 못한다.

typescript
type Dog = { name: string; breed: string };
type Cat = { name: string; indoor: boolean };

type Pet = Dog | Cat;

function describe(pet: Pet) {
  // pet.breed → 에러! Cat에는 breed가 없다
  // pet.indoor → 에러! Dog에는 indoor가 없다
}

공통 속성인 name은 접근 가능하지만, 각 타입 고유의 속성에는 접근할 수 없다. in 연산자나 타입 가드를 써서 좁힐 수는 있지만, 타입이 많아지면 매번 이런 검사를 작성하는 게 번거롭고 실수하기 쉽다.

Discriminated Union(판별 유니온)은 이 문제를 구조적으로 해결하는 패턴이다. 모든 멤버 타입에 공통된 리터럴 타입 필드(판별자, discriminant)를 하나 두고, 그 값으로 분기하면 TypeScript가 자동으로 타입을 좁혀준다.


기본 구조

판별 유니온의 핵심은 세 가지다.

  1. 공통 판별 필드: 모든 멤버 타입이 같은 이름의 필드를 갖는다
  2. 리터럴 타입 값: 각 멤버의 판별 필드 값이 서로 다른 리터럴 타입이다
  3. 타입 좁히기: 판별 필드를 검사하면 TypeScript가 나머지 필드를 자동 추론한다
typescript
type Circle = {
  kind: "circle";
  radius: number;
};

type Rectangle = {
  kind: "rectangle";
  width: number;
  height: number;
};

type Triangle = {
  kind: "triangle";
  base: number;
  height: number;
};

type Shape = Circle | Rectangle | Triangle;

여기서 kind가 판별자다. "circle", "rectangle", "triangle"은 모두 문자열 리터럴 타입이므로, TypeScript는 kind 값을 확인하는 것만으로 나머지 필드 구조를 정확히 파악한다.

typescript
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      // 여기서 shape은 Circle로 좁혀짐
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      // 여기서 shape은 Rectangle로 좁혀짐
      return shape.width * shape.height;
    case "triangle":
      // 여기서 shape은 Triangle로 좁혀짐
      return (shape.base * shape.height) / 2;
  }
}

별도의 타입 가드 함수를 작성할 필요가 없다. switchif로 판별 필드만 체크하면 TypeScript의 제어 흐름 분석(control flow analysis)이 나머지를 처리해준다.


판별자의 조건

아무 필드나 판별자가 되는 건 아니다. TypeScript가 판별 유니온으로 인식하려면 판별 필드가 리터럴 타입이어야 한다.

작동하는 판별자

typescript
// 문자열 리터럴
type A = { type: "a"; value: number };
type B = { type: "b"; label: string };

// 숫자 리터럴
type HttpOk = { status: 200; data: unknown };
type HttpNotFound = { status: 404; message: string };

// boolean 리터럴
type Success = { ok: true; data: unknown };
type Failure = { ok: false; error: Error };

// enum 멤버
enum EventType { Click, Hover, Scroll }
type ClickEvent = { type: EventType.Click; x: number; y: number };
type HoverEvent = { type: EventType.Hover; target: Element };

작동하지 않는 판별자

typescript
// ❌ string이나 number 같은 넓은 타입은 판별자가 될 수 없다
type A = { type: string; value: number };
type B = { type: string; label: string };

// TypeScript 입장에서 type이 "어떤 문자열"인지 모르니 좁힐 수가 없다

핵심은 각 멤버의 판별 필드 값이 서로 겹치지 않는 리터럴 타입이어야 한다는 것이다. 겹치면 TypeScript가 어느 멤버인지 구분할 수 없다.


소진 검사 (Exhaustive Check)

판별 유니온의 가장 강력한 기능 중 하나는 모든 케이스를 처리했는지 컴파일 타임에 검증할 수 있다는 것이다.

never를 이용한 소진 검사

switch에서 모든 케이스를 처리하면 default에 도달하는 값의 타입은 never가 된다. 이 성질을 이용해서 빠뜨린 케이스를 잡아낸다.

typescript
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default: {
      // 모든 케이스를 처리했으므로 shape은 never 타입
      const _exhaustive: never = shape;
      throw new Error(`Unknown shape: ${_exhaustive}`);
    }
  }
}

만약 나중에 Shape에 새 멤버를 추가하고 case를 빠뜨리면 어떻게 될까?

typescript
type Pentagon = { kind: "pentagon"; side: number };
type Shape = Circle | Rectangle | Triangle | Pentagon;

// getArea 함수의 default에서:
// const _exhaustive: never = shape;
// ❌ 컴파일 에러! Type 'Pentagon' is not assignable to type 'never'

TypeScript가 "Pentagon 케이스를 안 다뤘다"고 정확히 알려준다. 런타임 에러가 아니라 컴파일 에러이므로, 배포 전에 잡을 수 있다.

헬퍼 함수로 추상화

이 패턴을 반복적으로 쓴다면 헬퍼 함수를 만들어두면 편하다.

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${value}`);
}

function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      return assertNever(shape, `Unknown shape kind`);
  }
}

assertNever의 파라미터 타입이 never이므로, 처리하지 않은 멤버가 있으면 컴파일 에러가 발생한다. 간결하면서도 안전하다.

satisfies never 패턴 (TypeScript 4.9+)

TypeScript 4.9에서 도입된 satisfies 연산자를 활용하면 변수 선언 없이도 소진 검사가 가능하다.

typescript
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return (shape.base * shape.height) / 2;
    default:
      throw new Error(`Unknown: ${shape satisfies never}`);
  }
}

실전 패턴

API 응답 모델링

백엔드에서 오는 응답의 성공/실패를 타입 안전하게 다루는 가장 깔끔한 방법이다.

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; timestamp: number }
  | { status: "error"; error: { code: number; message: string } }
  | { status: "loading" };

function handleResponse(res: ApiResponse<User[]>) {
  switch (res.status) {
    case "success":
      // res.data: User[], res.timestamp: number 접근 가능
      renderUsers(res.data);
      break;
    case "error":
      // res.error.code, res.error.message 접근 가능
      showError(res.error.message);
      break;
    case "loading":
      // 추가 필드 없음
      showSpinner();
      break;
  }
}

이벤트 시스템

WebSocket 메시지, Redux action, 도메인 이벤트 등 다양한 종류의 이벤트를 하나의 유니온으로 모델링하는 패턴이다.

typescript
type AppEvent =
  | { type: "USER_LOGIN"; userId: string; timestamp: number }
  | { type: "USER_LOGOUT"; userId: string }
  | { type: "MESSAGE_SENT"; chatId: string; content: string }
  | { type: "FILE_UPLOADED"; fileId: string; size: number; mimeType: string };

function handleEvent(event: AppEvent) {
  switch (event.type) {
    case "USER_LOGIN":
      trackLogin(event.userId, event.timestamp);
      break;
    case "MESSAGE_SENT":
      notifyRecipients(event.chatId, event.content);
      break;
    // ... 각 이벤트마다 정확한 필드 접근 보장
  }
}

상태 머신

UI 컴포넌트의 상태를 판별 유니온으로 모델링하면, 불가능한 상태 조합이 타입 레벨에서 차단된다.

typescript
// ❌ 플래그 기반: 불가능한 조합이 허용됨
type FormState = {
  isLoading: boolean;
  isError: boolean;
  data?: User;
  error?: Error;
};
// isLoading: true, isError: true, data: someUser → 말이 안 되는 상태

// ✅ 판별 유니온: 불가능한 상태가 존재할 수 없음
type FormState =
  | { state: "idle" }
  | { state: "loading" }
  | { state: "success"; data: User }
  | { state: "error"; error: Error; retryCount: number };

플래그 기반이면 isLoadingisError가 동시에 true인 상태가 타입 상으로 가능하다. 판별 유니온을 쓰면 이런 모순이 구조적으로 불가능하다. 각 상태에 필요한 데이터만 정확히 들어있으니 dataundefined인지 체크하는 코드도 필요 없다.

typescript
function renderForm(state: FormState) {
  switch (state.state) {
    case "idle":
      return <EmptyForm />;
    case "loading":
      return <Spinner />;
    case "success":
      // state.data는 확실히 User — undefined 체크 불필요
      return <UserProfile user={state.data} />;
    case "error":
      // state.error는 확실히 Error
      return <ErrorMessage error={state.error} retry={state.retryCount} />;
  }
}

중첩 판별

복잡한 도메인에서는 판별을 중첩할 수도 있다.

typescript
type Payment =
  | {
      method: "card";
      cardType: "credit" | "debit";
      last4: string;
      installments?: number;
    }
  | {
      method: "bank";
      bankCode: string;
      accountLast4: string;
    }
  | {
      method: "wallet";
      provider: "kakao" | "naver" | "toss";
      pointUsed: number;
    };

function processPayment(payment: Payment) {
  switch (payment.method) {
    case "card":
      // payment.cardType, payment.last4 접근 가능
      if (payment.cardType === "credit" && payment.installments) {
        applyInstallment(payment.installments);
      }
      chargeCard(payment.last4);
      break;
    case "wallet":
      // payment.provider, payment.pointUsed 접근 가능
      deductFromWallet(payment.provider, payment.pointUsed);
      break;
    // ...
  }
}

일반 유니온과의 차이

판별 유니온이 아닌 일반 유니온도 TypeScript에서 자주 쓴다. 둘의 차이를 명확히 알아두자.

typescript
// 일반 유니온 — 타입 자체가 다름
type StringOrNumber = string | number;

// 판별 유니온 — 같은 구조의 객체, 판별 필드로 구분
type Result =
  | { type: "ok"; value: string }
  | { type: "err"; error: Error };
특성일반 유니온판별 유니온
구성원원시 타입, 클래스, 객체 등 자유공통 판별 필드를 가진 객체 타입
좁히기typeof, instanceof, in판별 필드 체크만으로 자동 좁히기
소진 검사가능하지만 번거로움never를 이용한 깔끔한 소진 검사
확장성멤버 추가 시 처리 누락 감지 어려움새 멤버 추가 → 미처리 코드 즉시 컴파일 에러

판별 유니온은 데이터의 "종류"가 여럿이고, 종류마다 구조가 다를 때 가장 빛난다.


판별 유니온 + 제네릭

제네릭과 결합하면 범용적인 타입을 만들 수 있다.

typescript
type AsyncState<T> =
  | { status: "idle" }
  | { status: "pending" }
  | { status: "fulfilled"; data: T }
  | { status: "rejected"; error: Error };

// 어떤 데이터든 비동기 상태로 감쌀 수 있다
type UserState = AsyncState<User>;
type PostsState = AsyncState<Post[]>;
type ConfigState = AsyncState<AppConfig>;

function unwrapOrThrow<T>(state: AsyncState<T>): T {
  if (state.status === "fulfilled") {
    return state.data; // T로 추론됨
  }
  if (state.status === "rejected") {
    throw state.error;
  }
  throw new Error(`Unexpected status: ${state.status}`);
}

주의할 점

판별 필드는 하나로 통일

여러 필드를 동시에 판별자로 쓰면 TypeScript가 좁히기를 제대로 못할 수 있다.

typescript
// ❌ 판별 필드가 두 개 → 혼란
type Event =
  | { source: "user"; action: "click"; x: number }
  | { source: "user"; action: "type"; key: string }
  | { source: "system"; action: "click"; triggeredBy: string };

// source로 좁혀도 action이 겹쳐서 구분이 안 됨

판별 필드는 하나만 쓰고, 세부 분류가 필요하면 중첩하거나 판별 값을 더 구체적으로 만드는 게 낫다.

typescript
// ✅ 판별 값을 구체적으로
type Event =
  | { type: "user_click"; x: number }
  | { type: "user_type"; key: string }
  | { type: "system_click"; triggeredBy: string };

선택적 필드와의 혼동

판별 유니온 멤버에 선택적 필드가 있으면 타입 좁히기와 무관하게 동작한다. 판별 필드 자체는 반드시 필수(non-optional)여야 한다.

typescript
// ❌ 판별 필드가 선택적이면 안 됨
type A = { kind?: "a"; value: number };
type B = { kind?: "b"; label: string };

// kind가 undefined일 수 있으므로 판별이 불가

if-else에서의 좁히기

switch뿐 아니라 if-else에서도 판별 유니온의 좁히기가 잘 동작한다. 다만 switch가 가독성과 소진 검사 면에서 더 자연스럽다.

typescript
function describe(shape: Shape): string {
  if (shape.kind === "circle") {
    return `반지름 ${shape.radius}인 원`;
  } else if (shape.kind === "rectangle") {
    return `${shape.width}x${shape.height} 직사각형`;
  } else {
    // shape은 Triangle로 좁혀짐
    return `밑변 ${shape.base}인 삼각형`;
  }
}

다만 else에서 자동으로 좁혀지는 것에 의존하면, 새 멤버 추가 시 else에 묻혀서 컴파일 에러가 나지 않을 수 있다. 가능하면 switch + default: assertNever()를 쓰자.


정리

판별 유니온은 TypeScript에서 복잡한 데이터 분기를 타입 안전하게 처리하는 핵심 패턴이다. 단순히 "타입을 좁히는 기법" 수준이 아니라, 애플리케이션의 상태 모델링 방식 자체를 바꿔준다.

  • 플래그 조합 대신 판별 유니온을 쓰면 불가능한 상태가 타입 레벨에서 차단된다
  • never 기반 소진 검사로 새 케이스 추가 시 처리 누락을 컴파일 타임에 감지한다
  • API 응답, 이벤트, 상태 머신, Redux action 등 거의 모든 "종류별 분기" 상황에 적용된다

"Make impossible states impossible"이라는 유명한 격언이 있다. 판별 유니온은 이 원칙을 TypeScript에서 실현하는 가장 실용적인 도구다.


관련 문서