정의
빠짐없음(exhaustiveness) 강제는 union의 모든 경우를 처리했는지 컴파일러가 대신 확인하게 만드는 것이에요.
목표는 union에 새 멤버를 추가했을 때, 그것을 처리해야 하는 모든 지점이 빨간 줄로 드러나는 것이에요. 사람이 기억해서 찾아다니지 않아도 되게요.
왜 필요한가
union에 경우를 하나 추가하는 건 아주 흔한 작업이에요. 주문 상태에 'refunded' 를 추가한다고 해볼게요.
type OrderStatus = "pending" | "paid" | "shipped" | "refunded"; // ← 추가
function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "결제 대기";
case "paid": return "결제 완료";
case "shipped": return "배송 중";
default: return "알 수 없음"; // ← 여기로 조용히 빠져요
}
}
컴파일은 통과해요. 그리고 화면에는 "알 수 없음"이 뜨죠. 새 상태를 처리해야 하는 곳이 이 함수 말고 다섯 군데 더 있다면, 그 다섯 곳도 전부 조용히 잘못 동작해요.
default 분기가 범인이에요. 편의로 넣은 catch-all이 누락을 숨겨요.
빠짐없음을 강제하면 이 작업이 완전히 달라져요. union에 한 줄 추가하는 순간 처리해야 할 곳이 전부 컴파일 에러로 나열돼요. 고칠 목록을 컴파일러가 만들어 주는 거예요.
동작 원리
수단이 세 계층이에요. 의존성이 없는 것부터 쓰고, 라이브러리는 조건이 맞을 때만 도입해요.
1계층 — assertNever (항상 쓸 수 있어요)
원리는 never 타입이에요. TypeScript는 모든 경우를 처리하고 나면 남은 값의 타입을 never로 좁혀요. never가 아닌 값이 남아 있으면 대입이 실패하고요.
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "결제 대기";
case "paid": return "결제 완료";
case "shipped": return "배송 중";
default: return assertNever(status);
// ^ 'refunded' is not assignable to 'never'
}
}
refunded를 처리하지 않으면 status가 'refunded'로 좁혀진 채 assertNever에 들어가고, 컴파일이 실패해요. catch-all을 없애는 게 아니라, catch-all을 컴파일 오류로 바꾸는 것이에요.
assertNever는 저장소에 이미 있으면 재사용하고, 없으면 공용 위치 한 곳에만 만들어요.
2계층 — satisfies Record (결과가 정적 값일 때)
경우마다 하는 일이 "값을 고르는 것"뿐이라면 switch보다 lookup 객체가 나아요.
const STATUS_LABEL = {
pending: "결제 대기",
paid: "결제 완료",
shipped: "배송 중",
refunded: "환불 완료",
} satisfies Record<OrderStatus, string>;
const label = STATUS_LABEL[status];
satisfies는 누락된 키와 추가된 키를 모두 거절하면서, 리터럴 타입은 그대로 보존해요. annotation(: Record<OrderStatus, string>)을 쓰면 값이 넓어져서 STATUS_LABEL.pending의 타입이 string이 되는데, satisfies는 "결제 대기"를 유지해요.
라벨뿐 아니라 렌더 함수, 권한 맵, 메시지 맵에도 그대로 통해요.
const STATUS_ICON = {
pending: ClockIcon,
paid: CheckIcon,
shipped: TruckIcon,
refunded: RefundIcon,
} satisfies Record<OrderStatus, ComponentType>;
키로 무엇을 쓸지 주의해요. 분기하는 union 그 자체를 써요.
// 리터럴 union이면 그대로
satisfies Record<OrderStatus, string>
// 태그 객체 union이면 indexed access로 태그를 꺼내요
satisfies Record<CheckoutState["status"], string>
3계층 — 패턴 매칭 라이브러리 (이미 설치돼 있을 때만)
ts-pattern 같은 라이브러리의 .exhaustive() 는 중첩 조건까지 빠짐없이 검사해 줘요.
match(mutation)
.with({ status: "error", error: { code: "CONFLICT" } }, () => showConflict())
.with({ status: "error" }, () => showGenericError())
.with({ status: "success" }, () => close())
.with({ status: "pending" }, () => showSpinner())
.exhaustive();
중첩 필드로 분기해야 할 때 1·2계층으로는 표현이 지저분해져요. 그럴 때만 가치가 있어요. 새 의존성을 추가할 만한 문제인지 먼저 판단하고, 이미 설치돼 있으면 편하게 쓰면 돼요.
실무 적용
라벨 맵이 필요하다고 태그 객체를 만들지 않기
이건 자주 하는 오판이에요.
// ❌ satisfies Record를 쓰려고 리터럴 union을 감쌌어요
type PaymentBadge = { kind: "unpaid" } | { kind: "paid" } | { kind: "refunded" };
// ✅ satisfies Record는 리터럴 union에 그대로 걸려요
type PaymentBadge = "unpaid" | "paid" | "refunded";
const BADGE_LABEL = { unpaid: "미결제", paid: "결제됨", refunded: "환불됨" }
satisfies Record<PaymentBadge, string>;
태그 객체는 멤버 둘 이상이 자기만의 필드를 가질 때만이에요. 감싸면 호출부마다 .kind를 벗기는 비용만 늘어요.
계층 고르는 순서
| 상황 | 계층 |
|---|---|
| 경우마다 로직이 다르고 early return이 섞임 | 1계층 (assertNever) |
| 경우마다 정적 값이나 컴포넌트를 고르기만 함 | 2계층 (satisfies Record) |
| 중첩 필드 조합으로 분기해야 함 + 라이브러리 이미 있음 | 3계층 (패턴 매칭) |
새 경우를 추가할 때의 흐름
빠짐없음이 잘 걸려 있으면 작업 순서가 이렇게 돼요.
- union에 멤버를 추가해요.
- 타입 검사를 돌려요.
- 컴파일러가 고칠 목록을 출력해 줘요.
- 목록을 위에서부터 처리해요.
- 목록이 비면 끝이에요.
이게 안 되면 3번이 "코드베이스를 grep해서 직접 찾기"로 바뀌어요.
트레이드오프
작성 시점에 손이 조금 더 가요. assertNever 헬퍼를 만들어야 하고, lookup 객체는 switch보다 낯설 수 있어요.
얻는 건 변경 시점의 안전이에요. 그리고 이 비용/이득 비율은 union이 오래 살수록 좋아져요. 한 번 쓰고 버릴 union이면 굳이 안 해도 되고, 도메인 상태처럼 계속 자라는 union이면 반드시 해야 해요.
또 하나 미묘한 비용이 있어요. 빠짐없음을 강제하면 union 추가가 "큰 변경"이 돼요. 다섯 군데가 빨개지니까요. 이건 사실 정확한 신호예요 — 원래 다섯 군데를 고쳐야 하는 변경이었으니까요. 다만 팀이 그 신호를 "귀찮다"로 받아들이면 default 분기를 다시 넣으려는 압력이 생겨요.
사용하면 안 되는 경우
- 소비자가 확장하는 열린 집합. 플러그인 키나 앱별 이벤트 이름처럼 소비자가 늘리는 집합은 애초에 닫힌 union이 아니에요. 빠짐없음이 아니라 typed registry나 module augmentation의 영역이에요.
- 정말로 기본 동작이 정의된 경우. "알려지지 않은 상태는 회색 배지"가 요구사항으로 정해져 있다면 catch-all이 맞아요. 다만 그때도
assertNever대신 명시적으로 그 정책을 적어 두세요. 습관적default와 구분되게요. - 한 파일 안에서만 쓰는 짧은 union. 사용처가 바로 옆에 있으면 컴파일러 도움 없이도 눈에 보여요.
흔한 실수
default: return '알 수 없음'을 넣어요. 편의로 넣은 한 줄이 모든 미래의 누락을 삼켜요.assertNever를 만들었는데default에 안 넣어요. 헬퍼만 있고 안 쓰면 아무 효과가 없어요.satisfies대신 annotation을 써요.const X: Record<K, string> = {...}는 누락은 잡지만 리터럴 타입을 넓혀요.Record의 키를 잘못 골라요. 태그 객체 union에Record<State, ...>를 쓰면 안 돼요.Record<State['status'], ...>로 태그를 꺼내야 해요.- 불가능한 조합까지 처리하려 해요. 애초에 union이 잘못 설계돼서 불가능한 상태가 표현 가능한 거라면, 빠짐없음을 강제하기 전에 union을 먼저 고쳐요.
- 라벨 맵을 쓰려고 리터럴 union을 태그 객체로 감싸요.
satisfies Record는 리터럴 union에 그대로 걸려요.
관련 개념
- state-modeling-ladder — 빠짐없음을 강제할 union을 만들어야 하는지부터 판단하기
- false-type-contracts —
satisfies를 런타임 검증으로 착각하지 않기 - type-level-testing — union을 넓혔을 때 실제로 테스트가 실패하는지 확인하기