Menu

TypeScript 판별 유니언(Discriminated Union)과 태그 유니언

판별 유니언은 kind나 status 같은 리터럴 태그 속성을 공유하는 객체 타입의 유니언입니다. 태그를 확인하면 객체 전체가 좁혀집니다. 패턴, switch 좁히기, never로 하는 완전성 검사, API 결과, 요청 상태, 상태 머신을 모델링하는 방법을 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

판별 유니언(태그 유니언이라고도 함)은 모든 멤버가 공통 속성 하나, 즉 태그를 갖고 멤버마다 그 값이 서로 다른 리터럴인 객체 타입의 유니언입니다. 태그를 확인하면 TypeScript가 어떤 멤버인지 알게 되고, 그 멤버의 다른 속성을 쓸 수 있게 됩니다.

case "circle" 안에서 shape.width를 읽으면 circle 멤버에는 width가 없으므로 컴파일 오류입니다. 태그는 평범한 데이터입니다. 런타임에는 그냥 문자열 속성이고, switch는 평범한 JavaScript입니다.

유니언을 판별 유니언으로 만드는 조건

태그로 좁히기는 세 가지 조건이 맞을 때 동작합니다.

  1. 모든 멤버가 객체 타입입니다.
  2. 모든 멤버가 같은 속성 이름(판별자)을 갖습니다. 이름은 자유이며 kind, type, status, tag가 흔합니다.
  3. 각 멤버에서 그 속성은 리터럴 타입입니다. 문자열, 숫자, 불리언 리터럴이나 null/undefined입니다.
type UiEvent =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string }
  | { type: "scroll"; delta: number };

type ApiResponse =
  | { ok: true; data: string }    // boolean literal tags
  | { ok: false; error: string };

type Message =
  | { version: 1; text: string }  // number literal tags
  | { version: 2; text: string; lang: string };

멤버 하나가 태그를 리터럴 대신 평범한 string으로 선언하면 태그를 비교해도 유니언이 더 이상 좁혀지지 않고, 멤버 전용 속성에 접근할 수 없습니다(TS2339).

태그로 좁히기

TypeScript가 이해하는 태그 검사라면 무엇이든 객체를 좁힙니다. switch, if/else, ===와 !==, 심지어 const로 구조 분해한 태그도 됩니다.

function describe(e: UiEvent): string {
  if (e.type === "click") return `click at ${e.x},${e.y}`;

  const { type } = e;        // destructured tags narrow too
  if (type === "keypress") return `key ${e.key}`;

  return `scroll by ${e.delta}`; // only "scroll" is left
}

"radius" in shape로 속성을 확인해도 좁혀지지만, 태그를 비교하는 편이 읽기 쉽고 완전성 검사도 가능하게 합니다.

완전성 검사

이 패턴의 가장 큰 장점은 유니언이 커질 때 드러납니다. 명시적인 반환 타입이 있고 default가 없으면 TypeScript는 switch가 모든 태그를 다뤄야 한다는 것을 알므로, case가 없는 새 멤버는 컴파일 오류가 됩니다.

index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

이 메시지는 어떤 case가 빠졌는지 말하지 않습니다. assertNever 헬퍼는 말해 주며, 외부 데이터(JSON, 오래된 클라이언트)가 타입상 존재할 수 없는 태그를 가져오면 런타임에 예외도 던집니다.

case 없이 다섯 번째 상태를 추가하면 assertNever(state) 줄이 멤버 이름과 함께 Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never'를 보고합니다. 더 자세한 내용은 never 페이지에 있습니다.

불가능한 상태를 불가능하게 만들기

위의 RequestState는 흔하지만 더 약한 모양을 대체합니다.

// Every combination is allowed, including nonsense
type LooseState<T> = {
  loading: boolean;
  data?: T;
  error?: string;
};

const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };

선택적 필드를 쓰면 "데이터와 오류가 둘 다 있는 로딩 중" 상태가 타입 검사를 통과하고, 읽는 사람은 모두 어떤 조합이 실제로 일어나는지 추측해야 합니다. 판별 유니언에서는 data가 success 상태에만, error가 error 상태에만 존재하므로 state.data를 읽는 코드는 먼저 success 상태임을 증명해야 합니다. 타입 자체가 유효한 상태를 문서화합니다.

결과 모델링: 성공 또는 실패

"성공했거나 실패했다"에는 불리언 태그로 충분합니다. 이 Result 타입은 잘못된 입력처럼 예상되는 실패에 예외를 던지는 대신 흔히 쓰는 방법입니다.

호출하는 쪽은 먼저 result.ok를 확인하지 않고는 result.value를 읽을 수 없습니다. 예외나 null 반환에서는 잊기 쉬운 바로 그 검사입니다.

상태 머신과 리듀서

상태를 위한 판별 유니언과 액션을 위한 판별 유니언 두 개로 상태 머신을 기술합니다. 리듀서는 액션에 대해 switch하고 다음 상태를 반환하며, 컴파일러는 반환된 각 객체가 유효한 상태인지 검사합니다.

{ ...state, name: "paused" }가 컴파일되는 것은 state가 먼저 playing 멤버로 좁혀졌기 때문이고, 그래서 복사본에 track과 position이 들어갑니다. { name: "paused" }만 반환하면 오류입니다. paused 상태에는 트랙이 필요합니다. Redux 리듀서와 React의 useReducer가 쓰는 것과 같은 모양입니다.

넓히기 함정

태그는 리터럴 타입으로 남아야 합니다. 표기 없이 변수에 만든 객체는 태그가 string으로 넓어져서 어떤 멤버와도 맞지 않습니다.

type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;

const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.

const ok1: Shape = { kind: "circle", radius: 2 };        // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const;      // or keep the literal with as const
area({ kind: "circle", radius: 2 });                     // or build it where Shape is expected

변경 가능한 속성은 넓어진다는 바탕 규칙은 리터럴 타입 페이지에서 설명합니다.

자주 묻는 질문

TypeScript에서 판별 유니언이란 무엇인가요?

모든 멤버가 같은 속성(판별자 또는 태그)을 서로 다른 리터럴 타입으로 갖는 객체 타입의 유니언입니다. 예: { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. shape.kind === "circle"을 확인하면 shape가 circle 멤버로 좁혀지므로 그 멤버의 다른 속성을 쓸 수 있습니다.

유니언과 판별 유니언의 차이는 무엇인가요?

판별 유니언은 규칙이 하나 더 붙은 유니언입니다. 모든 멤버가 고유한 리터럴 타입을 가진 속성 하나를 공유합니다. Cat | Fish 같은 평범한 유니언은 in이나 직접 만든 타입 가드로 좁혀야 하지만, 판별 유니언은 속성 하나를 비교해서 좁히고, 그 속성에 대한 switch는 완전성 검사를 받을 수 있습니다.

판별 유니언에 대한 switch를 완전하게 만들려면 어떻게 하나요?

함수에 명시적인 반환 타입을 주고 default를 두지 않거나(그러면 빠진 case는 오류 TS2366이 됩니다), function assertNever(x: never): never { throw ... }와 함께 default: return assertNever(value)를 추가하세요. 두 번째 방식은 처리하지 않은 멤버의 이름을 오류에 알려 주고, 예상치 못한 데이터가 들어오면 런타임에도 예외를 던집니다.

판별 유니언이 좁혀지지 않는 이유는 무엇인가요?

태그는 모든 멤버에서 리터럴 타입이어야 합니다. 표기 없이 만든 객체는 kind가 string으로 넓어져서 더 이상 어떤 멤버와도 맞지 않습니다(TS2345/TS2322). 표기를 더하거나, as const를 쓰거나, 유니언 타입이 기대되는 곳에서 바로 만들어서 고치세요. kind: string으로 타입이 지정된 멤버가 하나라도 있으면 유니언이 kind로 좁혀지지 않습니다.

판별자가 불리언이나 숫자일 수 있나요?

네. 어떤 리터럴 타입이든 됩니다. 문자열(kind: "circle"), 숫자(version: 2), 불리언(ok: true / ok: false), 심지어 null이나 undefined도 됩니다. 로그로 찍거나 JSON으로 보낼 때 읽기 쉬우므로 문자열 태그가 가장 흔합니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기