Discriminated Union
TypeScript에서 여러 타입을 유니온(|)으로 묶으면 "이 값은 A일 수도, B일 수도 있다"는 표현이 가능하다. 그런데 실제 코드에서는 A일 때와 B일 때 각각 다른 처리를 해야 한다. 단순한 유니온만으로는 TypeScript가 지금 이 값이 A인지 B인지 자동으로 구분해주지 못한다.
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가 자동으로 타입을 좁혀준다.
기본 구조
판별 유니온의 핵심은 세 가지다.
- 공통 판별 필드: 모든 멤버 타입이 같은 이름의 필드를 갖는다
- 리터럴 타입 값: 각 멤버의 판별 필드 값이 서로 다른 리터럴 타입이다
- 타입 좁히기: 판별 필드를 검사하면 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 값을 확인하는 것만으로 나머지 필드 구조를 정확히 파악한다.
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;
}
}
별도의 타입 가드 함수를 작성할 필요가 없다. switch나 if로 판별 필드만 체크하면 TypeScript의 제어 흐름 분석(control flow analysis)이 나머지를 처리해준다.
판별자의 조건
아무 필드나 판별자가 되는 건 아니다. 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 };
작동하지 않는 판별자
// ❌ string이나 number 같은 넓은 타입은 판별자가 될 수 없다
type A = { type: string; value: number };
type B = { type: string; label: string };
// TypeScript 입장에서 type이 "어떤 문자열"인지 모르니 좁힐 수가 없다
핵심은 각 멤버의 판별 필드 값이 서로 겹치지 않는 리터럴 타입이어야 한다는 것이다. 겹치면 TypeScript가 어느 멤버인지 구분할 수 없다.
소진 검사 (Exhaustive Check)
판별 유니온의 가장 강력한 기능 중 하나는 모든 케이스를 처리했는지 컴파일 타임에 검증할 수 있다는 것이다.
never를 이용한 소진 검사
switch에서 모든 케이스를 처리하면 default에 도달하는 값의 타입은 never가 된다. 이 성질을 이용해서 빠뜨린 케이스를 잡아낸다.
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를 빠뜨리면 어떻게 될까?
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 케이스를 안 다뤘다"고 정확히 알려준다. 런타임 에러가 아니라 컴파일 에러이므로, 배포 전에 잡을 수 있다.
헬퍼 함수로 추상화
이 패턴을 반복적으로 쓴다면 헬퍼 함수를 만들어두면 편하다.
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 연산자를 활용하면 변수 선언 없이도 소진 검사가 가능하다.
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 응답 모델링
백엔드에서 오는 응답의 성공/실패를 타입 안전하게 다루는 가장 깔끔한 방법이다.
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, 도메인 이벤트 등 다양한 종류의 이벤트를 하나의 유니온으로 모델링하는 패턴이다.
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 컴포넌트의 상태를 판별 유니온으로 모델링하면, 불가능한 상태 조합이 타입 레벨에서 차단된다.
// ❌ 플래그 기반: 불가능한 조합이 허용됨
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 };
플래그 기반이면 isLoading과 isError가 동시에 true인 상태가 타입 상으로 가능하다. 판별 유니온을 쓰면 이런 모순이 구조적으로 불가능하다. 각 상태에 필요한 데이터만 정확히 들어있으니 data가 undefined인지 체크하는 코드도 필요 없다.
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} />;
}
}
중첩 판별
복잡한 도메인에서는 판별을 중첩할 수도 있다.
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에서 자주 쓴다. 둘의 차이를 명확히 알아두자.
// 일반 유니온 — 타입 자체가 다름
type StringOrNumber = string | number;
// 판별 유니온 — 같은 구조의 객체, 판별 필드로 구분
type Result =
| { type: "ok"; value: string }
| { type: "err"; error: Error };
| 특성 | 일반 유니온 | 판별 유니온 |
|---|---|---|
| 구성원 | 원시 타입, 클래스, 객체 등 자유 | 공통 판별 필드를 가진 객체 타입 |
| 좁히기 | typeof, instanceof, in 등 | 판별 필드 체크만으로 자동 좁히기 |
| 소진 검사 | 가능하지만 번거로움 | never를 이용한 깔끔한 소진 검사 |
| 확장성 | 멤버 추가 시 처리 누락 감지 어려움 | 새 멤버 추가 → 미처리 코드 즉시 컴파일 에러 |
판별 유니온은 데이터의 "종류"가 여럿이고, 종류마다 구조가 다를 때 가장 빛난다.
판별 유니온 + 제네릭
제네릭과 결합하면 범용적인 타입을 만들 수 있다.
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가 좁히기를 제대로 못할 수 있다.
// ❌ 판별 필드가 두 개 → 혼란
type Event =
| { source: "user"; action: "click"; x: number }
| { source: "user"; action: "type"; key: string }
| { source: "system"; action: "click"; triggeredBy: string };
// source로 좁혀도 action이 겹쳐서 구분이 안 됨
판별 필드는 하나만 쓰고, 세부 분류가 필요하면 중첩하거나 판별 값을 더 구체적으로 만드는 게 낫다.
// ✅ 판별 값을 구체적으로
type Event =
| { type: "user_click"; x: number }
| { type: "user_type"; key: string }
| { type: "system_click"; triggeredBy: string };
선택적 필드와의 혼동
판별 유니온 멤버에 선택적 필드가 있으면 타입 좁히기와 무관하게 동작한다. 판별 필드 자체는 반드시 필수(non-optional)여야 한다.
// ❌ 판별 필드가 선택적이면 안 됨
type A = { kind?: "a"; value: number };
type B = { kind?: "b"; label: string };
// kind가 undefined일 수 있으므로 판별이 불가
if-else에서의 좁히기
switch뿐 아니라 if-else에서도 판별 유니온의 좁히기가 잘 동작한다. 다만 switch가 가독성과 소진 검사 면에서 더 자연스럽다.
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에서 실현하는 가장 실용적인 도구다.
관련 문서
- TypeScript 타입 가드 - 런타임 타입 좁히기 패턴
- Zod 스키마 검증 - 런타임 데이터 검증과 타입 추론
- 상태 머신 패턴 - FSM으로 상태 전이 관리