Discriminated Union(태그 유니온)은 공통 속성(판별자)으로 유니온 멤버를 구분 해서 안전하게 분기하는 패턴입니다.

기본 패턴

TYPESCRIPT
// 공통 속성 'type'이 판별자(discriminant)
type Circle = { type: 'circle'; radius: number };
type Square = { type: 'square'; side: number };
type Triangle = { type: 'triangle'; base: number; height: number };

type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
  switch (shape.type) {
    case 'circle':
      // shape: Circle — 자동으로 좁혀짐
      return Math.PI * shape.radius ** 2;
    case 'square':
      // shape: Square
      return shape.side ** 2;
    case 'triangle':
      // shape: Triangle
      return (shape.base * shape.height) / 2;
  }
}

shape.type을 체크하는 순간 TypeScript가 자동으로 타입을 좁혀 줍니다. 각 case 블록 안에서 해당 타입의 속성에만 접근할 수 있습니다.

왜 Discriminated Union인가

일반 유니온과 비교해 보겠습니다.

TYPESCRIPT
// ❌ 일반 유니온 — 어떤 타입인지 구분이 어려움
type Shape = { radius: number } | { side: number };

function getArea(shape: Shape) {
  if ('radius' in shape) {
    // 동작은 하지만 속성 이름에 의존
  }
}

// ✅ Discriminated Union — 판별자로 명확히 구분
type Shape2 =
  | { type: 'circle'; radius: number }
  | { type: 'square'; side: number };

function getArea2(shape: Shape2) {
  if (shape.type === 'circle') {
    // 명확하고 타입 안전
  }
}

Exhaustiveness Check

모든 경우를 빠짐없이 처리했는지 컴파일 타임에 검사할 수 있습니다.

TYPESCRIPT
type Shape = Circle | Square | Triangle;

function getArea(shape: Shape): number {
  switch (shape.type) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      // 모든 경우를 처리했으므로 shape: never
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

// 나중에 새 Shape를 추가하면?
// type Pentagon = { type: 'pentagon'; side: number; };
// type Shape = Circle | Square | Triangle | Pentagon;
// → default에서 에러: Type 'Pentagon' is not assignable to type 'never'

이 패턴은 새로운 유니온 멤버를 추가할 때 **처리하지 않은 곳을 자동으로 찾아 줍니다 **.

실전 활용

API 응답 상태 관리

TYPESCRIPT
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function renderUser(state: ApiState<{ name: string }>) {
  switch (state.status) {
    case 'idle':
      return '대기 중';
    case 'loading':
      return '로딩 중...';
    case 'success':
      return `이름: ${state.data.name}`; // data에 안전하게 접근
    case 'error':
      return `에러: ${state.error}`; // error에 안전하게 접근
  }
}

Redux 액션 패턴

TYPESCRIPT
type Action =
  | { type: 'INCREMENT'; payload: number }
  | { type: 'DECREMENT'; payload: number }
  | { type: 'RESET' };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT':
      return state + action.payload;
    case 'DECREMENT':
      return state - action.payload;
    case 'RESET':
      return 0;
  }
}

결과 타입(Result Pattern)

TYPESCRIPT
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { ok: false, error: '0으로 나눌 수 없습니다' };
  }
  return { ok: true, value: a / b };
}

const result = divide(10, 3);
if (result.ok) {
  console.log(result.value); // number
} else {
  console.log(result.error); // string
}

복합 판별자

TYPESCRIPT
// 판별자가 여러 속성일 수도 있음
type Event =
  | { source: 'user'; action: 'click'; x: number; y: number }
  | { source: 'user'; action: 'type'; text: string }
  | { source: 'system'; action: 'error'; code: number };

function handleEvent(event: Event) {
  if (event.source === 'user' && event.action === 'click') {
    console.log(event.x, event.y); // OK
  }
}

판별자로 사용할 수 있는 타입

판별자는 ** 리터럴 타입 **이어야 합니다.

TYPESCRIPT
// ✅ 문자열 리터럴
type A = { kind: 'a'; value: string };

// ✅ 숫자 리터럴
type B = { code: 200; data: any } | { code: 404; message: string };

// ✅ boolean
type C = { success: true; data: any } | { success: false; error: string };

// ❌ string, number 등 넓은 타입은 판별자로 사용 불가
// type Bad = { kind: string; value: any };

정리

  • Discriminated Union은 공통 판별자(tag)로 유니온 멤버를 구분한다
  • switch/if에서 판별자를 체크하면 TypeScript가 자동으로 타입을 좁힌다
  • never를 이용한 exhaustiveness check로 모든 경우를 처리했는지 검증한다
  • API 상태, Redux 액션, Result 패턴 등 실전에서 가장 많이 쓰는 TypeScript 패턴이다
  • 판별자는 리터럴 타입(문자열, 숫자, boolean)이어야 한다
댓글 로딩 중...