
구별된 유니온 또는 판별된 유니온 등 한글로는 다양하게 번역되지만, 특정 필드에 따라 식별되고, 의미가 더 와닿아 본문에서는 식별된 유니온이라는 번역을 사용하겠습니다.
식별된 유니온은 여러 타입을 하나의 유니온으로 묶되, 타입 끼리 공통된 리터럴 타입 필드(판별자, discriminant) 를 두어 TypeScript가 타입을 자동으로 좁혀주는 패턴입니다. 잠시 뒤 예시를 통해서 구체적으로 알아가봅시다.
| 요소 | 설명 |
|---|---|
| 판별자 (discriminant) | 각 유니온 멤버를 구분하는 공통 리터럴 필드 (예: status, kind, type) |
| 유니온 타입 | | 로 연결된 여러 객체 타입 |
| 타입 좁히기 (narrowing) | switch / if 분기에서 TypeScript가 자동으로 해당 타입을 추론 |
as 캐스팅 없이 안전하게 타입 좁히기 가능switch 문의 exhaustive check로 누락된 케이스를 컴파일 타임에 감지loading 상태에서 data 접근 불가)exhaustive check란?
switch문에서 모든 케이스를 빠짐없이 처리했는지 컴파일 타임에 검증하는 패턴
API 호출 결과를 UI에 렌더링하는 컴포넌트를 만들어야 합니다.
비동기 요청은 항상 4가지 상태를 가집니다.
idle → loading → success (data 포함)
↘ error (error 메시지 포함)
// ❌ 위험한 방식: 옵셔널 필드로 모든 상태를 하나의 타입에 때려넣기
type AsyncState<T> = {
isLoading: boolean;
data?: T;
error?: string;
};
function render(state: AsyncState<User>) {
// isLoading=false, data=undefined, error=undefined 조합이 가능해짐 → 어떤 상태인지 알 수 없음
if (state.data) {
return state.data.name; // data가 있어도 에러 상태일 수도 있음
}
}
문제점:
isLoading=false이면서 data도 error도 없는 유령 상태 가 존재// ✅ 각 상태를 명확하게 분리
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
status가 'idle'이면 data, error 모두 존재 자체가 불가능render 함수 완성하기타입 에러를 체크하면서 학습하기 위해서 TypeScript Playground에서 풀어보는걸 권장드립니다.
아래 타입과 함수를 완성하세요.
모든 상태(idle, loading, success, error)를 처리하고, never를 활용한 exhaustive check를 추가하세요.
문제 풀어보기 =>
type User = { id: number; name: string };
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function render(state: AsyncState<User>): string {
switch (state.status) {
case 'idle':
// TODO: 구현
case 'loading':
// TODO: 구현
case 'success':
// TODO: state.data.name 반환
case 'error':
// TODO: state.error 반환
default:
// TODO: exhaustive check 추가
}
}
다음 요구사항에 맞는 Discriminated Union 타입을 설계하세요.
결제 처리 시스템의 결제 상태를 표현해야 합니다.
pending: 결제 대기 중 (추가 정보 없음)approved: 결제 승인됨 (transactionId: string,approvedAt: Date포함)declined: 결제 거절됨 (reason: string포함)refunded: 환불됨 (refundedAt: Date,amount: number포함)
해당 타입을 받아 사용자에게 보여줄 메시지를 반환하는 getPaymentMessage 함수도 작성하세요.
type User = { id: number; name: string };
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
// exhaustive check를 위한 헬퍼
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function render(state: AsyncState<User>): string {
switch (state.status) {
case 'idle':
return '대기 중입니다.';
case 'loading':
return '로딩 중...';
case 'success':
// ✅ 이 분기에서 TypeScript는 state를 { status: 'success'; data: User }로 추론
return state.data.name;
case 'error':
// ✅ 이 분기에서 TypeScript는 state를 { status: 'error'; error: string }으로 추론
return `오류: ${state.error}`;
default:
// ✅ 모든 케이스를 처리했으므로 state는 never 타입
// 만약 새로운 status를 AsyncState에 추가하면 여기서 컴파일 에러 발생
return assertNever(state);
}
}
// 사용 예시
const states: AsyncState<User>[] = [
{ status: 'idle' },
{ status: 'loading' },
{ status: 'success', data: { id: 1, name: '김토스' } },
{ status: 'error', error: 'Network Error' },
];
states.forEach(state => console.log(render(state)));
// 대기 중입니다.
// 로딩 중...
// 김토스
// 오류: Network Error
type PaymentStatus =
| { kind: 'pending' }
| { kind: 'approved'; transactionId: string; approvedAt: Date }
| { kind: 'declined'; reason: string }
| { kind: 'refunded'; refundedAt: Date; amount: number };
function assertNever(value: never): never {
throw new Error(`Unhandled payment status: ${JSON.stringify(value)}`);
}
function getPaymentMessage(status: PaymentStatus): string {
switch (status.kind) {
case 'pending':
return '결제를 처리 중입니다. 잠시만 기다려 주세요.';
case 'approved':
// ✅ transactionId, approvedAt 접근 가능
return `결제가 완료되었습니다. (거래번호: ${status.transactionId})`;
case 'declined':
// ✅ reason 접근 가능
return `결제가 거절되었습니다. 사유: ${status.reason}`;
case 'refunded':
// ✅ refundedAt, amount 접근 가능
return `${status.amount.toLocaleString()}원이 환불 처리되었습니다.`;
default:
return assertNever(status);
}
}
// 사용 예시
console.log(getPaymentMessage({ kind: 'pending' }));
// 결제를 처리 중입니다. 잠시만 기다려 주세요.
console.log(getPaymentMessage({
kind: 'approved',
transactionId: 'TXN-20240506-001',
approvedAt: new Date(),
}));
// 결제가 완료되었습니다. (거래번호: TXN-20240506-001)
console.log(getPaymentMessage({ kind: 'declined', reason: '잔액 부족' }));
// 결제가 거절되었습니다. 사유: 잔액 부족
console.log(getPaymentMessage({
kind: 'refunded',
refundedAt: new Date(),
amount: 29000,
}));
// 29,000원이 환불 처리되었습니다.
| 개념 | 핵심 포인트 |
|---|---|
| 판별자 (discriminant) | 공통 리터럴 필드 (status, kind 등)로 유니온 멤버 구분 |
| 타입 좁히기 | switch/if 분기 내에서 TypeScript가 자동으로 정확한 타입 추론 |
| Exhaustive Check | default: assertNever(state)로 누락된 케이스를 컴파일 타임에 감지 |
| 안전성 | 불가능한 상태 조합을 타입 레벨에서 원천 차단 |
| 활용 분야 | 비동기 상태, 결제 상태, FSM(유한 상태 기계), 액션 타입(Redux) 등 |
"공통 판별자 필드로 각 상태를 분리하면, 타입 정의 시 불필요하게 Optional 한 필드를 추가하지 않고, 불필요한 타입가드 작성이 줄어들며 결국 복잡성을 낮추고, 에러 가능성을 미리 체크할 수 있게 된다. "