never 타입으로 빠진 분기를 컴파일 시점에 찾기
never 타입으로 빠진 분기를 컴파일 시점에 찾기
모든 경우를 처리한 뒤 남은 값의 타입은 never가 된다. default 분기에서 never를 요구하는 함수에 값을 넘기면 새 타입이 추가되었을 때 컴파일이 실패한다.
본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.
목차
- #왜 이 문제가 생기는가
- #never는 값이 존재할 수 없는 타입이다
- #새 상태가 추가될 때 컴파일 오류 만들기
- #객체 매핑에서도 빠진 키 찾기
- #noImplicitReturns도 같이 사용하기
- #컴파일 검사는 런타임 검증을 대신하지 않는다
- #결론
- #관련 노트
왜 이 문제가 생기는가
유니온 타입에 새 상태를 추가했는데 기존 switch 문이 조용히 기본값을 반환하면 일부 기능만 잘못 동작한다. 빠진 분기를 컴파일 오류로 바꾸면 변경 지점을 즉시 찾을 수 있다.
never는 값이 존재할 수 없는 타입이다
never는 null이나 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: 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로 검증해야 한다.