판별 유니언 — 타입 안전한 상태 모델링
TypeScript 판별 유니언(Discriminated Union)의 구조, 판별자 조건, switch 완전성 검사, 액션 패턴, 상태 머신 모델링까지 완전히 정리합니다.
지난 글에서 타입 가드로 런타임에 타입을 좁히는 방법을 배웠다. 이번에는 TypeScript의 가장 강력한 패턴 중 하나인 **판별 유니언(Discriminated Union)**을 살펴본다. 공통 리터럴 타입 필드를 통해 각 변체(variant)를 구분함으로써, 컴파일러가 완전성을 검사하고 각 분기에서 정확한 타입을 자동으로 제공한다.
판별 유니언의 세 가지 요소
판별 유니언이 되려면 세 조건을 갖춰야 한다.
- 여러 타입의 유니언
- 각 타입에 **공통 필드(판별자)**가 있어야 함
- 판별자의 값이 각 타입에서 고유한 리터럴 타입
// 올바른 판별 유니언 — 세 조건 충족
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Triangle = { kind: "triangle"; base: number; height: number };
type Shape = Circle | Square | Triangle;
kind 필드가 판별자다. 각 타입의 kind 값이 서로 다른 리터럴 타입이므로 컴파일러가 kind를 보고 어떤 타입인지 정확히 파악한다.
switch로 자동 좁히기
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2; // shape: Circle
case "square":
return shape.side ** 2; // shape: Square
case "triangle":
return 0.5 * shape.base * shape.height; // shape: Triangle
}
}
각 case 블록 내에서 shape는 해당 타입으로 자동으로 좁혀진다. shape.radius는 Circle 케이스에서만 접근할 수 있다.
비동기 상태 모델링
판별 유니언은 UI 상태 관리에서 특히 유용하다.
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function UserProfile({ state }: { state: AsyncState<User> }) {
switch (state.status) {
case "idle": return <p>시작하려면 버튼을 누르세요</p>;
case "loading": return <Spinner />;
case "success": return <Profile user={state.data} />;
case "error": return <ErrorMsg error={state.error} />;
}
}
status: "success" 케이스에서만 state.data에 접근할 수 있고, status: "error" 케이스에서만 state.error에 접근할 수 있다.
완전성 검사 (Exhaustiveness Check)
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function describe(shape: Shape): string {
switch (shape.kind) {
case "circle": return `반지름 ${shape.radius}`;
case "square": return `변 ${shape.side}`;
case "triangle": return `밑변 ${shape.base}`;
default:
return assertNever(shape); // Triangle 추가 빠뜨리면 오류
}
}
새로운 Shape 변체를 추가했을 때 switch 처리를 빠뜨리면 default 분기에서 컴파일 오류가 발생한다.
Redux 스타일 액션
type CounterAction =
| { type: "INCREMENT"; amount: number }
| { type: "DECREMENT"; amount: number }
| { type: "RESET" };
function counterReducer(state: number, action: CounterAction): number {
switch (action.type) {
case "INCREMENT": return state + action.amount;
case "DECREMENT": return state - action.amount;
case "RESET": return 0;
}
}
판별자 조건과 흔한 실수
좋은 판별자가 되려면 리터럴 타입이어야 한다.
// 나쁜 예: boolean은 두 케이스만 구분
type BadUnion =
| { isSuccess: true; data: string }
| { isSuccess: false; error: Error };
// boolean 대신 리터럴 문자열 사용 권장
// 나쁜 예: 선택적 속성은 판별자 역할 불가
type AlsoBad =
| { kind?: "a"; x: number }
| { kind?: "b"; y: string };
// kind가 undefined일 수 있어서 구분이 불명확
// 좋은 예: 필수 리터럴 속성
type Good =
| { kind: "a"; x: number }
| { kind: "b"; y: string };
중첩 판별 유니언
type ApiResponse<T> =
| { ok: true; status: 200; body: T }
| { ok: false; status: 400; message: string }
| { ok: false; status: 401; reason: "unauthorized" }
| { ok: false; status: 500; trace: string };
function handleResponse<T>(res: ApiResponse<T>) {
if (res.ok) {
// res: { ok: true; status: 200; body: T }
process(res.body);
} else {
switch (res.status) {
case 400: showError(res.message); break;
case 401: redirect("/login"); break;
case 500: reportError(res.trace); break;
}
}
}
판별 유니언은 “불가능한 상태를 표현 불가능하게 만들기(Making Impossible States Impossible)” 원칙의 핵심 도구다. 상태 필드들이 서로 독립적으로 존재할 때 발생하는 비일관적 조합을 타입 수준에서 원천 차단한다. 다음 글에서는 TypeScript의 모듈 시스템과 네임스페이스를 살펴본다.
지난 글: 타입 가드 — 런타임 타입 좁히기 기법
다음 글: 모듈과 네임스페이스 — TypeScript 코드 구조화
읽어주셔서 감사합니다. 😊