never 타입으로 빠진 분기를 컴파일 시점에 찾기

never 타입으로 빠진 분기를 컴파일 시점에 찾기

한눈에 보기

모든 경우를 처리한 뒤 남은 값의 타입은 never가 된다. default 분기에서 never를 요구하는 함수에 값을 넘기면 새 타입이 추가되었을 때 컴파일이 실패한다.

예시 코드 안내

본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.

목차

왜 이 문제가 생기는가

유니온 타입에 새 상태를 추가했는데 기존 switch 문이 조용히 기본값을 반환하면 일부 기능만 잘못 동작한다. 빠진 분기를 컴파일 오류로 바꾸면 변경 지점을 즉시 찾을 수 있다.

never는 값이 존재할 수 없는 타입이다

nevernull이나 undefined처럼 하나의 값이 아니다. 정상적으로 값이 만들어질 수 없는 위치를 나타낸다.

function fail(message: string): never {
  throw new Error(message);
}

function runForever(): never {
  while (true) {
    // 종료하지 않는다.
  }
}

두 함수 모두 호출자에게 값을 돌려주지 않는다. 하나는 항상 예외를 던지고 다른 하나는 끝나지 않는다.

타입 좁히기에서도 모든 가능성을 제거하고 나면 남은 타입이 never가 된다.

function format(value: string | number) {
  if (typeof value === "string") return value.trim();
  if (typeof value === "number") return value.toFixed(2);

  const unreachable: never = value;
  return unreachable;
}

현재 value는 string 또는 number뿐이고 두 경우를 모두 처리했다. 마지막 위치에 도달할 수 없으므로 value가 never로 좁혀진다.

새 상태가 추가될 때 컴파일 오류 만들기

배송 상태를 예로 들어 보자.

type DeliveryStatus =
  | "preparing"
  | "shipped"
  | "delivered";

function statusLabel(status: DeliveryStatus): string {
  switch (status) {
    case "preparing":
      return "상품 준비 중";
    case "shipped":
      return "배송 중";
    case "delivered":
      return "배송 완료";
    default:
      return assertNever(status);
  }
}

나중에 "cancelled"를 유니온에 추가하면 default 분기의 status는 더 이상 never가 아니다. assertNever(status)에서 컴파일 오류가 발생해 이 함수도 수정해야 한다는 사실을 알려준다.

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
default에서 임의의 문자열을 반환하지 않는다

default: return "알 수 없음"은 새 상태가 추가되어도 컴파일을 통과시킨다. 정말 fallback이 필요한 외부 데이터와 내부에서 빠진 분기를 찾아야 하는 도메인 타입을 구분한다.

객체 매핑에서도 빠진 키 찾기

switch를 사용하지 않는 UI 설정에도 exhaustive check를 적용할 수 있다.

type DeliveryStatus = "preparing" | "shipped" | "delivered";

const statusMeta = {
  preparing: { label: "상품 준비 중", color: "gray" },
  shipped: { label: "배송 중", color: "blue" },
  delivered: { label: "배송 완료", color: "green" },
} satisfies Record<
  DeliveryStatus,
  { label: string; color: string }
>;

satisfies는 모든 DeliveryStatus 키가 있는지 검사하면서 각 값의 구체적인 리터럴 정보는 유지한다. 상태를 추가하고 매핑을 빼먹으면 객체 선언에서 바로 오류가 난다.

function getStatusMeta(status: DeliveryStatus) {
  return statusMeta[status];
}

같은 방법은 권한별 메뉴, 이벤트 handler, locale 문구처럼 유니온의 모든 값을 매핑해야 하는 곳에 유용하다.

구조 exhaustive 검사가 어울리는 위치
switch 상태별 실행 로직
Record<Union, Value> 상태별 설정과 메타데이터
reducer 이벤트별 상태 변경
visitor AST 노드별 처리

noImplicitReturns도 같이 사용하기

반환 타입을 명시하고 noImplicitReturns를 켜면 일부 코드 경로가 값을 반환하지 않는 문제를 더 일찍 찾을 수 있다.

function statusLabel(status: DeliveryStatus): string {
  if (status === "preparing") return "상품 준비 중";
  if (status === "shipped") return "배송 중";
  // delivered 처리가 빠짐
}

다만 이 검사는 어떤 유니온 멤버가 빠졌는지까지 항상 친절하게 보여주지 않는다. 판별 유니온과 assertNever가 의도를 더 직접적으로 표현한다.

컴파일 검사는 런타임 검증을 대신하지 않는다

API에서 새로운 상태 문자열이 오면 TypeScript의 유니온 선언과 무관하게 런타임에 default 분기로 들어올 수 있다.

function parseDeliveryStatus(value: unknown): DeliveryStatus {
  switch (value) {
    case "preparing":
    case "shipped":
    case "delivered":
      return value;
    default:
      throw new Error(`Unsupported delivery status: ${String(value)}`);
  }
}

외부 경계에서는 unknown을 검증하고, 검증이 끝난 내부 유니온에는 exhaustive check를 사용한다.

flowchart LR
    A[외부 unknown] --> B[런타임 parser]
    B --> C[DeliveryStatus]
    C --> D[exhaustive switch]
    D --> E[새 상태 추가 시 컴파일 오류]

결론

never를 이용한 exhaustive check는 유니온에 새 상태가 추가될 때 놓친 분기를 컴파일 오류로 바꾼다. default에서 임의 값을 반환해 검사를 무력화하지 말고, 객체 매핑에는 satisfies를 이용해 키 누락을 확인할 수 있다. 이 보장은 TypeScript가 아는 값에만 적용되므로 외부 입력은 먼저 런타임 schema로 검증해야 한다.

관련 노트