TypeScript Bottom 타입 never 완벽 분석 – never의 다양한 응용 한눈에 보기
이번에는 특별한 TypeScript 타입인 never를 알아볼까 하는데요.
대략적으로 never는 절대로 발생하지 않는 현상의 타입이라고 볼 수 있습니다.
보시면 놀라울 정도의 다양한 응용 사례가 있는데요.
같이 알아보시죠.
잠깐! 여기서 사용된 표기법
이 글에서는 소스 코드 내에서 계산되거나 추론된 타입을 보여주기 위해 npm 패키지인 asserttt를 사용하겠습니다.
// Types of values
assertType<string>('abc');
assertType<number>(123);
// Equality of types
type Pair<T> = [T, T];
type _ = Assert<Equal<
Pair<string>, [string, string]
>>;
never는 Bottom 타입
타입을 값들의 집합으로 해석한다면, 아래와 같이 이해할 수 있습니다.
- 타입 Sub가 타입 Sup의 서브타입(Sub <: Sup)이라는 것은 Sub가 Sup의 부분 집합(Sub ⊂ Sup)임을 의미합니다.
- 특별한 두 종류의 타입이 존재하는데요:
- Top 타입 T는 모든 값을 포함하며 모든 타입이 T의 서브타입입니다.
- Bottom 타입 B는 공집합으로, 모든 타입의 서브타입입니다.
TypeScript에서는 다음과 같이 정의됩니다.
- any와 unknown은 Top 타입입니다.
- never는 Bottom 타입입니다.
never는 공집합
타입 연산을 수행할 때, 타입 유니온은 (타입 수준의) 값들의 집합을 표현하는 데 사용됩니다.
이때 공집합은 never로 표현됩니다:
type _ = [
Assert<Equal<
keyof { a: 1, b: 2 },
'a' | 'b' // set of types
>>,
Assert<Equal<
keyof {},
never // empty set
>>
];
마찬가지로, 공통 요소가 없는 두 타입을 & 연산자로 교차하면 결과는 공집합, 즉 never가 됩니다:
type _ = Assert<Equal<
boolean & symbol,
never
>>;
또한, 타입 연산자 |를 사용해 타입 T와 never의 유니온을 구하면 그 결과는 T가 됩니다:
type _ = Assert<Equal<
'a' | 'b' | never,
'a' | 'b'
>>;
never의 사용 예시: Union 타입 필터링
조건부 타입을 이용하여 유니온 타입을 필터링할 수 있습니다.
예를 들어, 문자열만 남기도록 필터링하는 타입을 정의해볼까요?
type KeepStrings<T> = T extends string ? T : never;
type _ = [
Assert<Equal<
KeepStrings<'abc'>, // normal instantiation
'abc'
>>,
Assert<Equal<
KeepStrings<123>, // normal instantiation
never
>>,
Assert<Equal<
KeepStrings<'a' | 'b' | 0 | 1>, // distributed instantiation
'a' | 'b'
>>
];
여기에는 두 가지 현상이 함께 작용합니다.
- 유니온 타입에 조건부 타입을 적용하면, 각각의 구성 요소에 분배되어 적용됩니다.
- 결과 유니온에서 조건이 맞지 않아 never가 반환된 경우에는 해당 타입이 사라집니다.
이를 통해 union 타입에서 원하는 타입만 걸러낼 수 있습니다.
never의 사용 예시: 컴파일 타임 케이스 누락 체크
enum을 이용해 컴파일 타임에 모든 경우를 처리했는지 검사하는 방법을 알아볼까요?
enum Color { Red, Green }
이 패턴은 JavaScript에서 잘 동작하는데요.
런타임에 color에 예상치 못한 값이 들어왔는지 확인할 수 있기 때문입니다:
function colorToString(color: Color): string {
switch (color) {
case Color.Red:
return 'RED';
case Color.Green:
return 'GREEN';
default:
throw new UnexpectedValueError(color);
}
}
타입 수준에서도 이 패턴을 지원할 수 있도록, 만약 enum Color의 멤버를 모두 고려하지 않으면 경고를 받도록 만들 수 있습니다.
(반환 타입을 string으로 지정함으로써 어느 정도의 안전은 확보되지만, 앞으로 소개할 기법을 통해 반환 구문 없이도 보호받을 수 있습니다. 또한 런타임의 유효하지 않은 값으로부터도 안전합니다.)
먼저, 케이스를 추가하면서 color의 추론된 타입이 어떻게 변하는지 살펴볼까요?
function colorToString(color: Color): string {
switch (color) {
default:
assertType<Color.Red | Color.Green>(color);
}
switch (color) {
case Color.Red:
break;
default:
assertType<Color.Green>(color);
}
switch (color) {
case Color.Red:
break;
case Color.Green:
break;
default:
assertType<never>(color);
}
}
따라서, 아래와 같이 UnexpectedValueError를 사용하여 color의 타입이 never임을 강제할 수 있습니다:
class UnexpectedValueError extends Error {
constructor(
// Type enables type checking
value: never,
// Avoid exception if `value` is:
// - object without prototype
// - symbol
message = `Unexpected value: ${{}.toString.call(value)}`
) {
super(message);
}
}
이제, 모든 경우를 처리하지 않으면 컴파일 타임에 경고를 받게 됩니다:
function colorToString(color: Color): string {
switch (color) {
case Color.Red:
return 'RED';
default:
assertType<Color.Green>(color);
// @ts-expect-error: Argument of type 'Color.Green' is not
// assignable to parameter of type 'never'.
throw new UnexpectedValueError(color);
}
}
if 문을 사용한 케이스 누락 체크
케이스 누락 체크는 if 문을 사용하더라도 동일하게 동작합니다:
function colorToString(color: Color): string {
assertType<Color.Red | Color.Green>(color);
if (color === Color.Red) {
return 'RED';
}
assertType<Color.Green>(color);
if (color === Color.Green) {
return 'GREEN';
}
assertType<never>(color);
throw new UnexpectedValueError(color);
}
never의 사용 예시: 속성 금지하기
다른 타입이 never에 할당될 수 없으므로, 이를 활용하여 특정 속성을 금지할 수 있습니다.
문자열 키를 가진 속성 금지하기
타입 EmptyObject는 문자열 키를 가진 속성을 금지합니다:
type EmptyObject = Record<string, never>;
// @ts-expect-error: Type 'number' is not assignable to type 'never'.
const obj1: EmptyObject = { prop: 123 };
const obj2: EmptyObject = {}; // OK
반면에, 타입 {}는 모든 객체를 허용하므로 빈 객체를 나타내기에는 적합하지 않습니다:
const obj3: {} = { prop: 123 };
숫자 키를 가진 인덱스 속성 금지하기
타입 NoIndices는 숫자 키를 가진 속성을 금지하면서 문자열 키인 'prop'
은 허용합니다:
type NoIndices = Record<number, never> & { prop?: boolean };
//===== Objects =====
const obj1: NoIndices = {}; // OK
const obj2: NoIndices = { prop: true }; // OK
// @ts-expect-error: Type 'string' is not assignable to type 'never'.
const obj3: NoIndices = { 0: 'a' }; // OK
//===== Arrays =====
const arr1: NoIndices = []; // OK
// @ts-expect-error: Type 'string' is not assignable to type 'never'.
const arr2: NoIndices = ['a'];
never를 반환하는 함수들
never는 결코 반환하지 않는 함수들을 나타내는 마커로도 사용됩니다.
예를 들어 볼까요?
function infiniteLoop(): never {
while (true) {}
}
function throwError(message: string): never {
throw new Error(message);
}
TypeScript의 타입 추론은 이러한 함수를 고려합니다.
예를 들어, 아래 returnStringIfTrue() 함수의 추론된 반환 타입은 throwError()를 호출하기 때문에 string
으로 추론됩니다:
function returnStringIfTrue(flag: boolean) {
if (flag) {
return 'abc';
}
throwError('Flag must be true'); // (A)
}
type _ = Assert<Equal<
ReturnType<typeof returnStringIfTrue>,
string
>>;
만약 (A) 줄을 생략하면, 추론된 반환 타입은 'abc' | undefined
가 됩니다:
function returnStringIfTrue(flag: boolean) {
if (flag) {
return 'abc';
}
}
type _ = Assert<Equal<
ReturnType<typeof returnStringIfTrue>,
'abc' | undefined
>>;
never | T 반환 타입에 반대하는 이유
원칙적으로, 예외를 발생시켜 정상적으로 반환되지 않는 경우 함수의 반환 타입으로 never | T를 사용할 수 있긴 합니다.
하지만 다음 두 가지 이유로 추천하지 않습니다.
- 예외를 던지는 것은 보통 함수의 반환 타입을 변경하지 않으므로, 이름 그대로 예외입니다.
- never | T는 앞서 살펴본 바와 같이 T와 동일합니다.
@types/node에서의 never 반환 타입
Node.js에서는 다음 함수들이 반환 타입이 never로 정의되어 있습니다:
process.exit();
process.abort();
assert.fail();
'Javascript' 카테고리의 다른 글
2025년에 Node.js에서 .env 파일 읽는 방법 (최신 정보) (0) | 2025.03.19 |
---|---|
Sharp.js - Node.js 최강 이미지 처리 프레임워크 완벽 가이드 (0) | 2025.03.19 |
TypeScript에서의 Array 타입 표기법: T[] vs. Array<T> 완벽 분석 (0) | 2025.03.19 |
Express.js 완벽 마스터하기: 초보자도 쉽게 배우는 미들웨어, next 메커니즘, 라우팅의 기초부터 활용까지 (0) | 2025.03.17 |
TypeScript 심볼 완벽 분석: 타입 레벨에서의 심볼 활용과 고급 패턴 (0) | 2025.03.15 |