본문으로 건너뛰기

식별 가능한 유니온

여러 상태를 하나의 타입으로 안전하게 표현하는 패턴과, 분기 누락을 컴파일 타임에 잡는 방법을 정리합니다.

핵심 내용

여러 상태를 하나의 타입으로 표현해야 할 때, 각 상태마다 공통된 리터럴 필드를 하나씩 넣어두면 TypeScript가 그 필드 값만 보고 나머지 구조를 정확히 추론해줍니다. 이 패턴을 식별 가능한 유니온(discriminated union)이라고 부릅니다.

나중에 상태가 하나 더 늘었는데 분기 처리를 깜빡하면, default 블록에서 never 타입에 대입하는 트릭으로 컴파일 타임에 강제로 잡아낼 수 있습니다. 이걸 exhaustiveness checking이라고 부릅니다.

예시

interface IdleState {
status: "idle";
}
interface SubmittingState {
status: "submitting";
}
interface SuccessState {
status: "success";
response: string;
}
interface ErrorState {
status: "error";
error: Error;
}

type FormState = IdleState | SubmittingState | SuccessState | ErrorState;

function handleForm(state: FormState) {
switch (state.status) {
case "idle":
break;
case "submitting":
break;
case "success":
console.log(state.response); // status로 좁혀졌으니 response 접근 가능
break;
case "error":
console.log(state.error.message);
break;
}
}
type Voucher = "10000" | "20000" | "5000"; // "5000"이 새로 추가됨

function getVoucherName(voucher: Voucher): string {
switch (voucher) {
case "10000":
return "1만원 상품권";
case "20000":
return "2만원 상품권";
default: {
const exhaustCheck: never = voucher; // 에러: '"5000"' 형식은 'never'에 할당할 수 없습니다
return `기타 상품권 ${exhaustCheck}`;
}
}
}

헷갈리기 쉬운 점

default 블록에서 왜 never에 대입하는 게 에러를 잡아주는지가 헷갈리기 쉽습니다. 모든 case를 다 처리했다면 default에 남는 값은 이론상 없어야 하니 타입이 never가 되고 대입이 성립합니다. 처리 안 된 케이스가 있으면 그 리터럴 값이 default까지 살아남아서 타입이 never가 아니게 되고, 대입 시점에 타입이 안 맞아 에러가 납니다. 컴파일러의 타입 검사 규칙을 역이용해서 "빠짐없이 다 처리했는지"를 자동으로 확인하는 트릭입니다.

실무에서 볼 점

  • loading, success, error처럼 UI 상태가 갈라질 때 하나의 status 필드로 상태를 구분하면 분기 처리가 단순해집니다.
  • 상태가 추가될 가능성이 있는 곳은 never 체크를 넣어두면 누락된 분기를 컴파일 단계에서 잡을 수 있습니다.

예상 질문

식별 가능한 유니온에서 never는 왜 쓰나요?

never는 더 이상 처리할 값이 남아 있지 않아야 한다는 표시로 씁니다.

새 유니온 값이 추가됐는데 switch에서 처리하지 않으면 그 값이 default까지 남고, never에 대입하는 순간 컴파일 에러가 나서 분기 누락을 잡을 수 있습니다.