Discriminated Union — 태그 기반 유니온으로 안전한 분기
Discriminated Union(태그 유니온)은 공통 속성(판별자)으로 유니온 멤버를 구분 해서 안전하게 분기하는 패턴입니다.
기본 패턴
// 공통 속성 '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인가
일반 유니온과 비교해 보겠습니다.
// ❌ 일반 유니온 — 어떤 타입인지 구분이 어려움
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
모든 경우를 빠짐없이 처리했는지 컴파일 타임에 검사할 수 있습니다.
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 응답 상태 관리
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 액션 패턴
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)
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
}
복합 판별자
// 판별자가 여러 속성일 수도 있음
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
}
}
판별자로 사용할 수 있는 타입
판별자는 ** 리터럴 타입 **이어야 합니다.
// ✅ 문자열 리터럴
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)이어야 한다
댓글 로딩 중...