새 타입을 하나 추가했을 뿐인데, 컴파일러가 "여기도 고쳐야 하지 않냐"라고 먼저 알려줬다.
문제: 컴파일러는 빠뜨린 케이스를 알려주지 않는다.
union type을 switch로 분기하는 코드는 흔하다.
문제는 union에 멤버가 추가됐을 때, 타입스크립트가 처리 안 한 케이스에 대해 침묵한다는 것이다.
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius 2;
case 'square':
return shape.sideLength 2;
}
}
여기에 Triangle을 추가해도 getArea는 아무 에러도 내지 않는다. triangle이 들어오면 조용히 undefined를 반환하고, 런타임에서야 터지는 버그가 된다. 타입은 안전하게 정의했는데, 그걸 소비하는 코드가 변화를 못 따라간 것이다.
왜 컴파일러는 알려주지 않을까?
결론부터 말하자면, 타입스크립트는 자바스크립트의 '유연한 런타임 동작'을 존중하기 때문이다. 자바스크립트에서는 함수가 끝날 때까지 명시적인 return 문을 만나지 못하면 자동으로 undefined를 반환한다. 타입스크립트는 이러한 JS의 태생적 특징을 그대로 따라간다. 어떤 case에도 걸리지 않아 switch 문을 빠져나왔을 때, 컴파일러는 개발자가 "실수로 빼먹었다"고 단정 짓지 않는다. 대신 "아, 조건에 맞지 않아서 의도적으로 undefined를 반환하려고 했구나"라고 해석한다.
즉, getArea의 반환 타입은 컴파일러 입장에서 number | undefined로 완벽하게 합법적인 추론이 된다. 타입 시스템상 모순이 없기 때문에 에러를 내지 않고 침묵하는 것이다. 컴파일러는 union이 어떤 멤버들로 구성되어 있는지는 알지만, 우리가 그것을 모두 처리해야만 한다는 의도까지는 읽지 못한다.
그렇다면 어떻게 컴파일러에게 우리의 의도를 강제할 수 있을까? 방법은 타입스크립트의 never 타입의 본질에 있다.
Never 타입의 동작
타입스크립트 컴파일러는 코드가 위에서 아래로 실행되는 흐름을 따라가며 가능성 있는 타입들을 하나씩 지워나간다.
이를 타입 좁히기라고 한다.
Shape = Circle | Square | Triangle 인 상황을 머릿속에 그려보자.
1. switch (shape.kind)에 진입할 때, shape의 타입 후보는 3개 (남은 후보: Circle, Square, Triangle)
2. case 'circle'을 통과하면서 Circle의 가능성이 지워짐. (남은 후보: Square, Triangle)
3. case 'square'를 통과하면서 Square의 가능성도 지워짐. (남은 후보: Triangle)
결국 마지막 default 블록에 도달했을 때, 컴파일러가 인식하는 shape의 타입은 오직 Triangle뿐이다.
여기서 never가 등장합니다. 타입 이론에서 never는 '어떤 값도 가질 수 없는 공집합(Empty Set)'을 의미하는 바닥 타입이다. never 타입의 변수에는 오직 never만 할당할 수 있으며, any를 포함해서 그 외의 어떤 타입도 들어갈 수 없다.
뒤에 이어서 설명을 하겠지만, 우리가 default 블록에 const _exhaustiveCheck: never = shape;를 작성하는 것은 컴파일러에게 다음과 같은 논리적 트릭을 거는 것과 같다.
- 케이스를 모두 처리했을 때: default에 도달한 shape에 남은 타입 후보는 "없음(공집합)"이다. 즉, 타입이 never가 됩니다. never를 never에 할당하는 것이니 컴파일러는 조용히 통과시킨다.
- Triangle 케이스를 빼먹었을 때: default에 도달한 shape의 타입은 Triangle입니다. 이때 컴파일러는 "아무것도 없어야 할 공집합(never) 자리에 Triangle이라는 실체가 들어오려고 한다" 며 즉시 타입 불일치 에러를 뱉는다.
해결: never를 활용한 Exhaustive Check
never는 절대 존재할 수 없는 값이다. switch에서 모든 케이스를 좁히고 나면, default에 남는 값의 타입은 자동으로 never가 된다. 이 값을 never 변수에 할당해 보자.
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius 2;
case 'square':
return shape.sideLength 2;
default: {
// ❌ 'Triangle' 형식은 'never' 타입에 할당할 수 없습니다.
const _exhaustiveCheck: never = shape;
return _exhaustiveCheck;
}
}
}
케이스를 다 처리했으면 통과하고, 빠뜨린 게 있으면 컴파일 에러를 마주할 수 있다.
실제 적용기: 다이얼로그 하나, 세 가지 제출 타입
결과 제출 다이얼로그는 type prop으로 어떤 제출인지를 받는다. 각 타입은 다른 API에서, 다른 형태의 데이터를 가져온다.
해당 다이얼로그는 추후에 다른 type들로 충분히 확장될 가능성을 가지고 있었고, 그때 놓치고 싶지 않아 exhaustive check라는 방법을 찾아보게 되었다.
const getCurrentData = () => {
switch (type) {
case 'duprSubmit':
return duprStatus?.eligibility;
case 'openPlayDuprSubmit':
return openPlayDuprStatus?.eligibility;
case 'rankMatchSubmit':
return rankMatchStatus;
default: {
const _exhaustiveCheck: never = type;
throw new Error(Unhandled type: ${_exhaustiveCheck});
}
}
};
duprSubmit, rankMatchSubmit만 있던 시절에 openPlayDuprSubmit을 추가하자, 이 default가 곧장 컴파일 에러를 띄웠다. 덕분에 새 타입의 데이터 분기를 추가해야 한다는 걸 코드를 실행하기도 전에 알 수 있었다.
참고: assertUnreachable 유틸
매번 const _exhaustiveCheck: never = ...를 쓰는 대신 유틸로 빼두면 의도가 더 명확해진다.
function assertUnreachable(value: never): never {
throw new Error(Unexpected value: ${value});
}
// default: return assertUnreachable(type);
- 컴파일 타임 안전성: 타입 추가 시 고칠 곳을 컴파일러가 짚어줌
- 런타임 안전성: 예상 못 한 값에 의미 있는 에러를 던짐
- 가독성: 이 함수는 모든 케이스를 의도적으로 다 처리한다는 점을 인지시킴

마치며
타입을 정의하는 것만큼 중요한 건, 그 타입이 어디서 어떻게 소비되는지 끝까지 추적하는 것이라고 생각한다.
Exhaustive check는 그 추적을 사람에게 의존하는 것이 아니라 컴파일러에게 위임하는 장치다. 사람이기에 놓칠 수 있는 실수를 시스템적으로 방어하는 방법을 배울 수 있었던 값진 경험이었다.
참고:
https://yceffort.kr/2026/01/typescript-exhaustive-check
https://ui.toast.com/posts/ko_20220323
'Front-End > TypeScript' 카테고리의 다른 글
| [TypeScript] TypeScript 연습문제 1단계 (2) | 2024.11.26 |
|---|