타입스크립트를 쓰다 보면 never와 unknown을 마주칩니다. 분명 내가 적은 적 없는데 에러 메시지에 never가 튀어나오기도 하고, 라이브러리 타입에서 unknown을 받아 어떻게 다뤄야 할지 막막할 때도 있습니다.
두 타입은 정반대 자리에 있습니다. never는 "어떤 값도 될 수 없는" 바닥 타입(bottom type)이고, unknown은 "어떤 값이든 될 수 있는" 꼭대기 타입(top type)입니다. 이 위치만 잡아두면 둘이 왜 그렇게 동작하는지 자연스럽게 풀립니다. 이전 글에서 다룬 타입 좁히기가 여기서도 핵심 도구로 쓰입니다.
타입에도 바닥과 꼭대기가 있다
타입을 "그 타입에 속하는 값의 집합"으로 보면 이해가 쉽습니다. string은 모든 문자열의 집합이고, boolean은 true와 false 두 값의 집합입니다.
이 관점에서 unknown은 가능한 모든 값을 담은 가장 큰 집합입니다. 무엇이든 unknown에 넣을 수 있습니다. 반대로 never는 원소가 하나도 없는 빈 집합입니다. never 타입의 값은 만들 수 없습니다.
그래서 둘의 사용법도 정반대입니다. unknown은 너무 넓어서 꺼내 쓰기 전에 좁혀야 하고, never는 값이 없어서 거기에 도달하면 안 되는 지점을 표시하는 데 쓰입니다.
unknown: any보다 안전한 꼭대기 타입
unknown은 무엇이든 받을 수 있습니다. 하지만 받은 다음이 다릅니다. 타입을 좁히기 전에는 거의 아무 연산도 허용하지 않습니다.
1function lengthOf(input: unknown): number {
2 return input.length;
3 // error TS18046: 'input' is of type 'unknown'.
4}
5input이 문자열인지 배열인지 숫자인지 모르니, length가 있다고 보장할 수 없다는 것입니다. 컴파일러는 좁히기를 요구합니다.
1function lengthOf(input: unknown): number {
2 // 좁히기를 거쳐야만 안전하게 접근할 수 있다.
3 if (typeof input === 'string' || Array.isArray(input)) {
4 return input.length;
5 }
6 return 0;
7}
8
9console.log(lengthOf('hello')); // 5
10console.log(lengthOf([1, 2, 3])); // 3
11console.log(lengthOf(42)); // 0
12여기서 any와 비교하면 unknown의 가치가 분명해집니다. any는 검사를 통째로 꺼버립니다.
1const dangerous: any = 'just a string';
2dangerous.foo.bar.baz; // 컴파일러가 막지 않는다. 런타임에 터질 코드인데도 통과한다.
3
4const safe: unknown = 'just a string';
5safe.foo; // error TS18046: 'safe' is of type 'unknown'.
6둘 다 "타입을 모른다"를 표현하지만, any는 모른다는 이유로 검사를 포기하고, unknown은 모르기 때문에 오히려 확인을 강제합니다. 외부에서 들어오는 값, 예를 들어 JSON.parse의 결과나 catch 절의 에러처럼 타입을 단정할 수 없는 자리에는 any 대신 unknown을 두는 편이 안전합니다.
never: 발생할 수 없는 값
never는 우리가 직접 적기보다 컴파일러가 추론해서 등장하는 경우가 많습니다. 대표적으로 불가능한 교차 타입입니다.
1// string이면서 동시에 number인 값은 존재할 수 없다.
2type Impossible = string & number; // 타입은 never
3string과 number를 동시에 만족하는 값은 없으므로, 교차한 결과는 빈 집합, 즉 never가 됩니다. 타입을 잘못 조합했을 때 에러 메시지에 never가 보이는 건 보통 이런 이유입니다.
또 하나는 정상적으로 반환하지 않는 함수입니다. 예외를 던지거나 무한 루프에 빠지는 함수는 반환 타입이 never입니다.
1function fail(message: string): never {
2 throw new Error(message);
3}
4never가 모든 타입의 서브타입이라는 점은 이런 함수와 잘 맞습니다. 빈 집합은 모든 집합의 부분집합이니, never는 어떤 타입 자리에도 끼어들 수 있습니다. 덕분에 아래처럼 한 분기에서 fail을 호출하면, 그 분기는 값을 반환하지 않아도 타입 검사를 통과합니다.
1function assertString(value: unknown): string {
2 if (typeof value === 'string') {
3 return value;
4 }
5 // fail의 반환 타입이 never라, 이 분기는 값을 반환하지 않아도 된다.
6 return fail('문자열이 아닙니다');
7}
8
9console.log(assertString('ok')); // ok
10// assertString(123) → Error: 문자열이 아닙니다
11never로 빠짐없이 분기 검사하기
never가 가장 실용적으로 빛나는 곳은 판별 유니온(discriminated union)의 빠짐없는 분기 검사, 이른바 exhaustive check입니다.
도형 타입이 두 종류라고 해봅시다. switch로 모든 종류를 처리한 뒤, default에서 남은 값을 never에 할당합니다.
1type Shape =
2 | { kind: 'circle'; radius: number }
3 | { kind: 'square'; size: number };
4
5function area(shape: Shape): number {
6 switch (shape.kind) {
7 case 'circle':
8 return Math.PI * shape.radius ** 2;
9 case 'square':
10 return shape.size ** 2;
11 default: {
12 // 모든 케이스를 처리했다면, 여기 도달하는 shape의 타입은 never다.
13 const _exhaustive: never = shape;
14 return _exhaustive;
15 }
16 }
17}
18
19console.log(area({ kind: 'circle', radius: 2 }).toFixed(2)); // 12.57
20console.log(area({ kind: 'square', size: 3 })); // 9
21모든 kind를 처리하면 default에 도달하는 shape의 타입은 never로 좁혀집니다. 그래서 never에 할당하는 코드가 문제없이 통과합니다.
진가는 나중에 종류를 하나 추가할 때 드러납니다. triangle을 유니온에 더했는데 switch에 케이스를 빠뜨리면, default로 흘러드는 shape에는 아직 triangle 타입이 남아 있습니다. never가 아닌 값을 never에 넣으려 하니 컴파일 에러가 납니다.
1type Shape =
2 | { kind: 'circle'; radius: number }
3 | { kind: 'square'; size: number }
4 | { kind: 'triangle'; base: number; height: number }; // 새 변형을 추가했는데
5
6function area(shape: Shape): number {
7 switch (shape.kind) {
8 case 'circle':
9 return Math.PI * shape.radius ** 2;
10 case 'square':
11 return shape.size ** 2;
12 // triangle 케이스를 처리하는 걸 깜빡했다.
13 default: {
14 const _exhaustive: never = shape;
15 // error TS2322: Type '{ kind: "triangle"; base: number; height: number; }'
16 // is not assignable to type 'never'.
17 return _exhaustive;
18 }
19 }
20}
21
"케이스 누락 시 컴파일 에러를 보여주는 터미널 화면. tsc 실행 결과 error TS2322, triangle 타입을 never에 할당할 수 없다는 메시지가 빨간색으로 출력된다."
런타임에 도형을 그려보고 나서야 빠뜨린 걸 아는 게 아니라, 타입을 추가한 순간 컴파일러가 "여기 처리 안 했어요"라고 알려줍니다. 종류가 늘어나는 유니온을 다룰 때 이 패턴 하나로 누락을 컴파일 타임에 막을 수 있습니다.
void와 never는 다르다
never를 처음 보면 void와 헷갈리기 쉽습니다. 둘 다 "반환값이 마땅치 않은" 자리에 쓰이지만 의미가 다릅니다.
void는 함수가 정상적으로 끝나되 의미 있는 값을 반환하지 않는다는 뜻입니다. 반면 never는 함수가 끝까지 도달하지 못한다는 뜻입니다. 예외를 던지거나 무한 루프에 빠지는 함수입니다.
1function logMessage(msg: string): void {
2 console.log(msg); // 정상적으로 끝나지만, 반환하는 값은 없다.
3}
4
5function throwError(msg: string): never {
6 throw new Error(msg); // 애초에 반환 지점에 도달하지 못한다.
7}
8void인 함수는 호출 다음 줄이 실행되지만, never인 함수는 호출한 시점에서 흐름이 끊깁니다. 반환 타입만 봐도 이 함수가 정상 종료되는지 알 수 있는 셈입니다.
정리
이번 글에서는 정반대 자리에 있는 두 타입, never와 unknown을 살펴봤습니다.
| 타입 | 위치 | 의미 | 주로 쓰는 곳 |
|---|---|---|---|
unknown | 꼭대기(top) | 어떤 값이든 될 수 있음 | 외부 입력, JSON.parse, catch 에러 |
never | 바닥(bottom) | 어떤 값도 될 수 없음 | 반환하지 않는 함수, exhaustive check |
any | (검사 끔) | 타입 검사를 포기 | 가급적 피하고 unknown으로 대체 |
void | - | 정상 종료하되 반환값이 없음 | 부수 효과만 있는 함수 |
unknown은 너무 넓어서 좁히기 전에는 쓸 수 없다. 그래서any보다 안전하다.never는 빈 집합이라 값이 없고, "도달하면 안 되는 지점"을 표시하는 데 쓴다.- 판별 유니온에서
never할당으로 exhaustive check를 걸면, 케이스 누락을 컴파일 타임에 잡는다. never는 반환하지 못하는 함수,void는 반환값이 없는 함수다.
마치며
never와 unknown은 처음엔 구석에 있는 특수한 타입처럼 보이지만, 위치를 잡고 나면 매일 쓰는 도구가 됩니다. 특히 exhaustive check는 한 번 익혀두면 유니온을 다룰 때마다 안전망이 되어줍니다. 긴 글 읽어주셔서 감사합니다.