shape 타입을 리팩토링하고 새로운 variant를 추가했는데도 TypeScript는 초록불이었다. CI를 통과했고, 배포도 나갔다. 그런데 사용자가 undefined를 반환하는 런타임 분기를 만났고, 앱이 프로덕션에서 터져 버렸다.
범인은 거의 확실히 switch 문이었다. TypeScript의 switch에는 exhaustiveness 보장이 없다. union에 새 멤버를 추가하면, 그 union 위의 모든 switch가 조용히 불완전해진다. compiler가 막지 않는다. 경고조차 주지 않는다. 사람들이 헷갈려하는 부분이 바로 여기다: TypeScript는 가능한 값의 전체 집합을 알고 있지만, switch 문은 exhaustiveness checking에 참여하지 않는다.
Exhaustiveness checking이 실제로 의미하는 것
Exhaustiveness는 compiler가 모든 가능한 값이 처리되었음을 증명할 수 있음을 의미한다. Rust나 OCaml 같은 언어는 이를 컴파일 시점에 강제한다. TypeScript는 그렇지 않다. 적어도 switch 문에 대해서는.
Discriminated union을 보자:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
// triangle is missing — and TypeScript is fine with it
}
}
이것은 컴파일된다. shape.kind === "triangle"일 때는 undefined를 반환한다. 함수 시그니처는 number를 약속하지만, 구현은 그것을 지키지 않는다. TypeScript는 switch block의 완전성을 검사하지 않기 때문에 누락된 case를 표시하지 않는다.
Object lookup 패턴
가장 간단한 대체재는 plain object map이다. 각 key는 handler function에 매핑된다. discriminant로 객체를 인덱싱하면 compiler가 key의 존재를 확인한다.
const areaHandlers: Record<Shape["kind"], (shape: Shape) => number> = {
circle: (s) => Math.PI * (s as Extract<Shape, { kind: "circle" }>).radius ** 2,
rectangle: (s) =>
(s as Extract<Shape, { kind: "rectangle" }>).width *
(s as Extract<Shape, { kind: "rectangle" }>).height,
triangle: (s) =>
0.5 *
(s as Extract<Shape, { kind: "triangle" }>).base *
(s as Extract<Shape, { kind: "triangle" }>).height,
};
function area(shape: Shape): number {
return areaHandlers[shape.kind](shape);
}
이게 더 낫다. Shape에 "polygon"을 추가하면 TypeScript는 areaHandlers에 key가 없다고 불평한다. 에러는 올바른 위치를 가리키고, 컴파일 시점에 발생한다.
하지만 cast는 보기 흉하다. 더 나은 방법이 있다.
Casting 없는 type-safe handler
요령은 각 variant를 narrowed type을 직접 받는 function에 매핑하는 것이다. mapped type을 사용해 key와 특정 shape variant 사이의 관계를 보존한다.
type ShapeKind = Shape["kind"];
type AreaMap = {
[K in ShapeKind]: (shape: Extract<Shape, { kind: K }>) => number;
};
const areaHandlers: AreaMap = {
circle: (s) => Math.PI * s.radius ** 2,
rectangle: (s) => s.width * s.height,
triangle: (s) => 0.5 * s.base * s.height,
};
function area(shape: Shape): number {
const handler = areaHandlers[shape.kind];
return handler(shape as never);
}
이제 각 handler는 제대로 narrowed된 인자를 받는다. 함수 안에는 수동 cast가 없다. 마지막 줄의 as never는 작은 흠이지만, 국소적이고 안전하다. 진짜 가치는 areaHandlers가 ShapeKind의 모든 key를 포함해야 한다는 점이다. 하나를 제거하거나 Shape에 새 variant를 추가하면 빌드가 깨진다.
Never assert로 exhaustiveness checking 하기
어떤 팀은 switch를 유지하되 컴파일 시점 exhaustiveness guard를 추가하는 것을 선호한다. 이것은 pragmatic한 중간 지점이다. never를 받는 default branch를 추가해 compiler가 모든 case가 처리되었음을 검증하도록 강제한다.
function assertNever(x: never): never {
throw new Error("Unexpected value: " + x);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
새로운 shape kind를 추가하고 case를 추가하는 것을 잊으면, default branch는 non-never 값을 받는다. TypeScript는 error를 낸다. concrete type을 never에 할당할 수 없기 때문이다. 에러는 switch를 가리키는데, 그게 바로 원하는 위치다.
이 패턴은 Sentry와 다른 대규모 TypeScript codebase에서 널리 사용된다. 코드 구조를 바꿀 필요가 없다. compiler가 당신을 방해하는 대신 당신을 위해 일하도록 만들 뿐이다.
서드파티 라이브러리로 pattern matching 하기
Rust의 match에 더 가까운 것을 원한다면, ts-pattern 같은 라이브러리가 내장된 exhaustiveness checking과 함께 pattern matching을 제공한다.
import { match, P } from "ts-pattern";
function area(shape: Shape): number {
return match(shape)
.with({ kind: "circle" }, (s) => Math.PI * s.radius ** 2)
.with({ kind: "rectangle" }, (s) => s.width * s.height)
.with({ kind: "triangle" }, (s) => 0.5 * s.base * s.height)
.exhaustive();
}
.exhaustive() 호출이 핵심이다. variant가 빠지면 TypeScript가 error를 보고한다. 이 라이브러리는 nested pattern, guard, wildcard도 지원하므로 복잡한 중첩 switch block을 대체할 수 있다.
이것은 강력하지만 dependency를 추가한다. 단일 discriminated union에는 ts-pattern은 overkill이다. pattern matching이 많은 codebase라면 bundle size는 감수할 만하다.
Switch 문이 여전히 유리한 경우
Switch가 항상 잘못된 것은 아니다. case가 fall through가 필요하거나, early return과 side effect가 있는 imperative한 로직이라면 switch가 lookup table이나 .with() 호출 체인보다 더 읽기 쉬울 수 있다.
문제는 syntax가 아니다. Exhaustiveness checking의 부재다. switch를 사용한다면 assertNever guard를 추가하라. 두 줄의 코드로 safety gap을 메울 수 있다.
전체를 다시 작성하지 않고 도입하는 방법
오늘 당장 모든 switch를 리팩토링할 필요는 없다. 실용적인 작업 순서는 다음과 같다:
assertNever를 shared utilities에 추가한다. 새로운 switch block에서 사용한다.- 기존의 union 위의 switch를 건드릴 때, 그때 guard를 추가한다.
- 새로운 discriminated union 코드라면, 로직이 pure하고 각 case가 독립적이라면 object-map 패턴을 선호한다.
- nested switch나 복잡한 matching logic을 몇 번 이상 작성하게 된다면
ts-pattern을 검토한다.
목표는 switch 문을 없애는 것이 아니다. compiler가 사용자보다 먼저 버그를 잡게 만드는 것이다.
실제로 보고 싶다면 AreaMap 예시에서 handler 하나를 삭제하고 tsc가 불평하는 것을 지켜봐라. 그 빨간 물결표가 조용한 금요일 배포와 주말 pagerduty page의 차이다.