본문으로 건너뛰기
Advertisement

6.1 판별 유니온

판별 유니온이란?

여러 타입이 비슷하게 생겼을 때, "이 값이 정확히 어떤 종류인가?"를 타입스크립트에게 알려주는 방법이 필요합니다. **판별 유니온(Discriminated Union)**은 각 타입에 공통된 리터럴 필드(판별자)를 두어, 그 값으로 타입을 구분하는 패턴입니다.

판별자(discriminant)로는 보통 kind, type, tag, status 같은 이름의 필드를 사용합니다. 이 필드의 값은 각 타입마다 고유한 문자열(또는 숫자) 리터럴이어야 합니다.

판별 유니온 = 공통 리터럴 필드 + 유니온 타입

판별 유니온을 사용하면:

  • 타입스크립트가 switch/if 분기에서 자동으로 타입을 좁혀줍니다.
  • 런타임 안전성과 컴파일 타임 안전성을 동시에 얻습니다.
  • 새 타입을 추가할 때 누락된 처리를 컴파일러가 경고해 줍니다.

핵심 개념

1. 기본 판별 유니온

판별자 필드를 공유하는 여러 인터페이스를 유니온으로 묶습니다.

// 각 도형은 'kind' 필드로 구분됩니다
interface Circle {
kind: 'circle'; // 리터럴 타입
radius: number;
}

interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}

interface Triangle {
kind: 'triangle';
base: number;
height: number;
}

// 판별 유니온
type Shape = Circle | Rectangle | Triangle;

2. switch + 타입 좁히기

switch 문에서 판별자 필드를 검사하면, 각 case 안에서 타입이 자동으로 좁혀집니다.

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;
}
}

3. never를 이용한 Exhaustive Check (완전성 검사)

새 타입이 추가됐을 때 처리를 빠뜨리면 컴파일 오류가 발생하도록 강제할 수 있습니다.

// never를 이용한 exhaustive check 헬퍼
function assertNever(value: never): never {
throw new Error(`처리되지 않은 케이스: ${JSON.stringify(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:
// Shape에 새 타입이 추가되면 이 줄에서 컴파일 오류 발생
return assertNever(shape);
}
}

assertNevershape를 넘기면, 모든 케이스를 처리한 뒤에는 shapenever 타입이 됩니다. 만약 처리하지 않은 케이스가 남아 있다면 never가 아닌 타입이 전달되어 컴파일 오류가 발생합니다.


코드 예제

예제 1: kind 필드 기반 기본 구조

interface LoadingState {
kind: 'loading';
}

interface SuccessState {
kind: 'success';
data: string[];
}

interface ErrorState {
kind: 'error';
message: string;
code: number;
}

type FetchState = LoadingState | SuccessState | ErrorState;

function renderState(state: FetchState): string {
switch (state.kind) {
case 'loading':
return '로딩 중...';
case 'success':
return `데이터: ${state.data.join(', ')}`;
case 'error':
return `오류 [${state.code}]: ${state.message}`;
default:
return assertNever(state);
}
}

// 사용
const loading: FetchState = { kind: 'loading' };
const success: FetchState = { kind: 'success', data: ['사과', '바나나'] };
const error: FetchState = { kind: 'error', message: '네트워크 오류', code: 500 };

console.log(renderState(loading)); // 로딩 중...
console.log(renderState(success)); // 데이터: 사과, 바나나
console.log(renderState(error)); // 오류 [500]: 네트워크 오류

예제 2: 중첩 판별 유니온

판별 유니온은 중첩해서 사용할 수도 있습니다.

// 알림 종류
interface EmailNotification {
channel: 'email';
to: string;
subject: string;
body: string;
}

interface SMSNotification {
channel: 'sms';
phoneNumber: string;
message: string;
}

interface PushNotification {
channel: 'push';
deviceToken: string;
title: string;
body: string;
badge?: number;
}

type Notification = EmailNotification | SMSNotification | PushNotification;

// 알림 전송 결과
interface NotificationResult {
notification: Notification;
status: 'sent' | 'failed';
timestamp: Date;
error?: string;
}

function formatNotification(notification: Notification): string {
switch (notification.channel) {
case 'email':
return `이메일 → ${notification.to}: ${notification.subject}`;
case 'sms':
return `SMS → ${notification.phoneNumber}: ${notification.message}`;
case 'push':
return `푸시 → ${notification.deviceToken}: ${notification.title}`;
default:
return assertNever(notification);
}
}

예제 3: 상태 머신으로서의 판별 유니온

주문 처리 흐름을 판별 유니온으로 표현합니다.

interface PendingOrder {
status: 'pending';
orderId: string;
items: string[];
createdAt: Date;
}

interface ConfirmedOrder {
status: 'confirmed';
orderId: string;
items: string[];
createdAt: Date;
confirmedAt: Date;
estimatedDelivery: Date;
}

interface ShippedOrder {
status: 'shipped';
orderId: string;
items: string[];
createdAt: Date;
confirmedAt: Date;
shippedAt: Date;
trackingNumber: string;
}

interface DeliveredOrder {
status: 'delivered';
orderId: string;
items: string[];
createdAt: Date;
deliveredAt: Date;
}

interface CancelledOrder {
status: 'cancelled';
orderId: string;
items: string[];
createdAt: Date;
cancelledAt: Date;
reason: string;
}

type Order =
| PendingOrder
| ConfirmedOrder
| ShippedOrder
| DeliveredOrder
| CancelledOrder;

// 상태 전이 함수 — 잘못된 전이는 타입 오류로 방지
function confirmOrder(order: PendingOrder): ConfirmedOrder {
return {
...order,
status: 'confirmed',
confirmedAt: new Date(),
estimatedDelivery: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
};
}

function shipOrder(order: ConfirmedOrder, trackingNumber: string): ShippedOrder {
return {
...order,
status: 'shipped',
shippedAt: new Date(),
trackingNumber,
};
}

function getOrderSummary(order: Order): string {
const base = `주문 #${order.orderId}`;

switch (order.status) {
case 'pending':
return `${base} — 결제 대기 중`;
case 'confirmed':
return `${base} — 배송 준비 중 (예상: ${order.estimatedDelivery.toLocaleDateString()})`;
case 'shipped':
return `${base} — 배송 중 (운송장: ${order.trackingNumber})`;
case 'delivered':
return `${base} — 배달 완료`;
case 'cancelled':
return `${base} — 취소됨 (사유: ${order.reason})`;
default:
return assertNever(order);
}
}

실전 예제

실전 1: HTTP 요청 상태 관리

// 제네릭 판별 유니온으로 재사용 가능한 비동기 상태 타입
type AsyncState<T, E = Error> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T; timestamp: Date }
| { kind: 'error'; error: E; retryCount: number };

// React-style 훅 시뮬레이션
interface User {
id: number;
name: string;
email: string;
}

type UserState = AsyncState<User, { message: string; statusCode: number }>;

function renderUserState(state: UserState): string {
switch (state.kind) {
case 'idle':
return '데이터를 불러오려면 버튼을 누르세요.';
case 'loading':
return '사용자 정보를 불러오는 중...';
case 'success':
return `
이름: ${state.data.name}
이메일: ${state.data.email}
조회 시각: ${state.timestamp.toLocaleTimeString()}
`;
case 'error':
return `오류 ${state.error.statusCode}: ${state.error.message} (재시도: ${state.retryCount}회)`;
default:
return assertNever(state);
}
}

// 상태 전이 헬퍼
function toLoading<T, E>(state: AsyncState<T, E>): AsyncState<T, E> {
return { kind: 'loading' };
}

function toSuccess<T, E>(data: T): AsyncState<T, E> {
return { kind: 'success', data, timestamp: new Date() };
}

function toError<T, E>(error: E, retryCount = 0): AsyncState<T, E> {
return { kind: 'error', error, retryCount };
}

실전 2: Redux-style Action 타입

// Action 타입 정의
interface FetchUsersAction {
type: 'users/fetch';
}

interface FetchUsersSuccessAction {
type: 'users/fetchSuccess';
payload: User[];
}

interface FetchUsersFailureAction {
type: 'users/fetchFailure';
payload: string;
error: true;
}

interface AddUserAction {
type: 'users/add';
payload: Omit<User, 'id'>;
}

interface RemoveUserAction {
type: 'users/remove';
payload: number; // userId
}

interface UpdateUserAction {
type: 'users/update';
payload: Partial<User> & { id: number };
}

type UsersAction =
| FetchUsersAction
| FetchUsersSuccessAction
| FetchUsersFailureAction
| AddUserAction
| RemoveUserAction
| UpdateUserAction;

interface UsersState {
list: User[];
loading: boolean;
error: string | null;
}

// 리듀서 — 모든 액션을 타입 안전하게 처리
function usersReducer(state: UsersState, action: UsersAction): UsersState {
switch (action.type) {
case 'users/fetch':
return { ...state, loading: true, error: null };

case 'users/fetchSuccess':
return { ...state, loading: false, list: action.payload };

case 'users/fetchFailure':
return { ...state, loading: false, error: action.payload };

case 'users/add': {
const newUser: User = {
id: Math.max(0, ...state.list.map(u => u.id)) + 1,
...action.payload,
};
return { ...state, list: [...state.list, newUser] };
}

case 'users/remove':
return { ...state, list: state.list.filter(u => u.id !== action.payload) };

case 'users/update':
return {
...state,
list: state.list.map(u =>
u.id === action.payload.id ? { ...u, ...action.payload } : u
),
};

default:
return assertNever(action);
}
}

실전 3: 결제 방법 처리

// 결제 방법별 필요 데이터가 다릅니다
interface CardPayment {
method: 'card';
cardNumber: string; // 마지막 4자리
expiryDate: string; // MM/YY
cardholderName: string;
cvv: string;
}

interface BankTransferPayment {
method: 'bankTransfer';
bankCode: string;
accountNumber: string;
accountHolder: string;
}

interface CouponPayment {
method: 'coupon';
couponCode: string;
discountType: 'percentage' | 'fixed';
discountValue: number;
}

interface PointPayment {
method: 'point';
pointsToUse: number;
availablePoints: number;
}

type PaymentMethod = CardPayment | BankTransferPayment | CouponPayment | PointPayment;

interface PaymentRequest {
orderId: string;
amount: number;
payment: PaymentMethod;
}

function validatePayment(payment: PaymentMethod, amount: number): string[] {
const errors: string[] = [];

switch (payment.method) {
case 'card':
if (!/^\d{4}$/.test(payment.cardNumber)) {
errors.push('카드 번호 마지막 4자리를 올바르게 입력하세요.');
}
if (!/^\d{2}\/\d{2}$/.test(payment.expiryDate)) {
errors.push('유효기간 형식이 올바르지 않습니다 (MM/YY).');
}
break;

case 'bankTransfer':
if (!payment.bankCode) {
errors.push('은행을 선택해 주세요.');
}
if (!/^\d+$/.test(payment.accountNumber)) {
errors.push('계좌번호는 숫자만 입력 가능합니다.');
}
break;

case 'coupon':
if (payment.discountType === 'percentage' && payment.discountValue > 100) {
errors.push('할인율은 100%를 초과할 수 없습니다.');
}
if (payment.discountType === 'fixed' && payment.discountValue > amount) {
errors.push('쿠폰 할인 금액이 주문 금액을 초과합니다.');
}
break;

case 'point':
if (payment.pointsToUse > payment.availablePoints) {
errors.push(`보유 포인트(${payment.availablePoints}P)가 부족합니다.`);
}
if (payment.pointsToUse > amount) {
errors.push('사용 포인트가 결제 금액을 초과합니다.');
}
break;

default:
assertNever(payment);
}

return errors;
}

function getPaymentSummary(payment: PaymentMethod): string {
switch (payment.method) {
case 'card':
return `신용/체크카드 (**** ${payment.cardNumber})`;
case 'bankTransfer':
return `계좌이체 (${payment.accountHolder})`;
case 'coupon':
return `쿠폰 (${payment.couponCode}) — ${
payment.discountType === 'percentage'
? `${payment.discountValue}% 할인`
: `${payment.discountValue.toLocaleString()}원 할인`
}`;
case 'point':
return `포인트 (${payment.pointsToUse.toLocaleString()}P 사용)`;
default:
return assertNever(payment);
}
}

고수 팁

팁 1: Exhaustive Check 헬퍼 함수

// 방법 1: throw를 던지는 런타임 헬퍼
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unhandled discriminated union case: ${JSON.stringify(value)}`);
}

// 방법 2: 컴파일 타임 전용 (런타임 오버헤드 없음)
function exhaustiveCheck(_: never): void {
// 컴파일 타임에만 사용되는 더미 함수
}

// 방법 3: 결과를 반환하는 헬퍼
function matchNever<T>(value: never, fallback: T): T {
return fallback;
}

// 사용 예시
function processShape(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:
exhaustiveCheck(shape); // 컴파일 오류만 유발, 런타임엔 도달 불가
return 0; // TypeScript가 never를 이해하므로 unreachable
}
}

팁 2: 판별 유니온 vs 클래스 계층

// 방법 A: 판별 유니온 (함수형 스타일)
type Result<T, E> =
| { 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, 2);
if (result.ok) {
console.log(result.value); // 5
} else {
console.error(result.error);
}

// 방법 B: 클래스 계층 (OOP 스타일)
abstract class ResultClass<T, E> {
abstract isOk(): this is OkResult<T, E>;
}

class OkResult<T, E> extends ResultClass<T, E> {
constructor(public readonly value: T) { super(); }
isOk(): this is OkResult<T, E> { return true; }
}

class ErrResult<T, E> extends ResultClass<T, E> {
constructor(public readonly error: E) { super(); }
isOk(): this is OkResult<T, E> { return false; }
}

// 판별 유니온이 더 나은 경우:
// - 단순 데이터 컨테이너
// - 직렬화/역직렬화가 필요한 경우 (JSON)
// - 함수형 패턴 선호
// - 타입 합치기·분리가 잦은 경우

// 클래스 계층이 더 나은 경우:
// - 메서드와 상태가 강하게 결합된 경우
// - 상속 체계가 의미 있는 경우
// - 의존성 주입, 모킹이 필요한 경우

팁 3: 판별자 자동 추출 유틸리티 타입

// 판별 유니온에서 판별자 값을 추출하는 유틸리티
type DiscriminatorValues<
T,
K extends keyof T
> = T extends Record<K, infer V> ? V : never;

type ShapeKind = DiscriminatorValues<Shape, 'kind'>;
// 'circle' | 'rectangle' | 'triangle'

// 판별자 값으로 해당 타입을 추출하는 유틸리티
type ExtractByDiscriminator<
T,
K extends keyof T,
V extends T[K]
> = T extends Record<K, V> ? T : never;

type CircleType = ExtractByDiscriminator<Shape, 'kind', 'circle'>;
// Circle

팁 4: 매핑 함수(match) 구현

// 판별 유니온을 위한 패턴 매칭 헬퍼
type MatchHandlers<T extends { kind: string }, R> = {
[K in T['kind']]: (value: Extract<T, { kind: K }>) => R;
};

function match<T extends { kind: string }, R>(
value: T,
handlers: MatchHandlers<T, R>
): R {
const handler = handlers[value.kind as T['kind']];
return (handler as (v: T) => R)(value);
}

// 사용
const area = match(shape, {
circle: s => Math.PI * s.radius ** 2,
rectangle: s => s.width * s.height,
triangle: s => (s.base * s.height) / 2,
});

정리 표

개념설명사용 시점
판별 유니온공통 리터럴 필드로 구분되는 유니온 타입여러 형태의 데이터를 하나의 타입으로 표현
판별자(discriminant)kind, type, tag 같은 리터럴 타입 필드각 케이스를 고유하게 식별할 때
switch 타입 좁히기switch case 안에서 자동으로 타입이 구체화분기 처리 시
exhaustive checknever로 모든 케이스 처리 여부를 컴파일 타임에 검증유니온 케이스가 추가될 수 있을 때
assertNever처리 누락 시 런타임 오류를 발생시키는 헬퍼방어적 프로그래밍
상태 머신 표현상태 전이를 타입으로 강제주문, 결제, 폼 흐름 등
판별 유니온 vs 클래스데이터 중심이면 유니온, 메서드 중심이면 클래스설계 방향에 따라 선택

다음 장에서는 판별 유니온과 밀접하게 연결된 **타입 가드(6.2)**를 살펴봅니다. typeof, instanceof, in 연산자부터 사용자 정의 타입 가드(value is Type)와 Assertion Functions까지, 타입을 좁히는 다양한 기법을 학습합니다.

Advertisement