오늘은 타입스크립트의 never
타입에 대해서 타파해보고자 포스팅합니다!
never
타입을 사용하면서 공집합이란 개념을 통해 이해했는데, 가끔 객체의 key 값으로 key extends A ? never: key
이런 삼항연산자를 사용하면서, 이때의 never
는 뭐야? 싶을 때가 많았어서 좋은 외부 포스팅글을 참고하여 포스팅합니다.
이 포스팅은 아래와 같은 내용을 포함하고 있습니다.
- 내용
- never의 의미와 필요성
- never의 애플리케이션의 실 사용과 함정
📌 never 타입이란?
never
를 이해하기 위해선 타입이 무엇이고, 타입이란 시스템에서 어떤 역할을 해야하는지 이해해야합니다.
타입은 가능한 값의 집합을 뜻합니다. 예를 들어, string
은 문자열이 될 수 있는 모든 값의 집합인데요, 그래서 변수 타입을 string
으로 지정하면, 해당 변수는 오직 가능한 집합 내의 값만 가질 수 있습니다.
let foo: string = 'foo';
foo = 1; // error
타입스크립트에서 never
타입의 값은 공집합입니다. 또 다른 인기 자바스크립트 타입 시스템인 Flow
에서 never
타입은 empty
타입과 같습니다. 집합에 어떤 것도 없기 때문에 never
타입은 any
타입의 값을 포함에 어떤 값도 가질 수 없습니다. 그래서 never
타입은 때로는 점유할 수 없는 값, 또는 바닥 타입이라고 불립니다.
declare const any: any;
const never: never = any // error: any는 never 타입에 할당할 수 없음
바닥 타입이라는 말은 타입스크립트 핸드북에서 never
타입을 정의하는 말입니다. 그렇다면 이 never
타입은 왜 필요할까요?
📌 never 타입이 필요한 이유
숫자 체계에서도 아무것도 없는 양을 나타내기 위해서 0
이 존재하는데요, 타입에서도 불가능을 뜻하는 타입이 필요합니다.
"불가능"이라는 말 그자체는 모호한데요, 타입스크립트에서 "불가능"을 아래와 같이 다양한 방법으로 나타내고 있습니다.
- 값을 포함할 수 없는 빈 타입
- 제네릭과 함수에서 허용되지 않는 매개변수
- 호환되지 않는 타입들의 교차 타입
- 빈 합집합(무의 합집합)
- 실행이 끝날 때 호출자에게 제어를 반환하지 않는 함수의 반환 타입
- ex. Node의 process.exit
- void는 호출자에게 함수가 유용한 것을 반환하지 않는다는 것이므로 혼동하지 않도록 한다.
- 절대로 도달할 수 없을 else 분기의 조건 타입
- 거부된 Promise에서 처리된 값의 타입
const p = Promise.reject('foo') // const p: Promise<never>
📌 유니언/교차 타입과 never의 동작
숫자 0이 덧셈과 곱셈의 작용 방법과 유사하듯이 never
타입도 유니언/교차 타입에서 특별한 속성을 가집니다.
- 숫자에 0을 더하면 동일한 숫자가 나오는 것과 같이, never 타입은 유니언 타입에서 없어짐
type Res = string | never // string
- 숫자에 0을 곱하면 0이 나오는 것과 같이, never 타입은 교차 타입을 덮어쓴다.
type Res = string & never // never
이는 객체 타입에서 좀 더 복잡하게 작동합니다.
type Foo = {
name: string;
age: number;
}
type Bar = {
name: number,
age: number,
}
type Baz = Foo & Bar // {name: never, age: number}
📌 never 타입을 쓰는 방법
never
타입을 많이 사용하지 않을 수 있지만, 아래와 같이 적절한 사용 사례가 많이 있습니다.
아까 위의 사례들 중 하나인데요, 허용할 수 없는 함수 매개변수에 제한을 가하는 것입니다.
never 타입을 이용해서 다양한 사용 사례에 놓인 함수에 제안을 걸 수 있습니다.
허용할 수 없는 함수 매개변수에 제한을 가한다.
함수가 단 하나의 never
타입 인수만 받을 수 있는 경우, 해당 함수를 never
타입 이외의 타입을 가지고 호출할 수 없게 됩니다.
function fn(input: never) {}
declare let myNever: never
fn(myNever); // okay
fn() // error: 아무 매개변수도 넣지 않아서
fn('name') // error: 문자열 넣을 수 없음
fn(100) // error: 숫자 넣을 수 없음
declare let myAny: any
fn(myAny) // error: any도 넣을 수 없음
switch, if-else 문의 모든 상황을 보장한다.
function fn(a: number | string) {
if(typeof a === 'number' || typeof a === 'string'){
return a;
} else if(typeof a === 'string') {
return a + 'hello!'
}
}
위의 예시에서 else if
구문은 실행되지 않는데요, 이때 리턴문에 있는 a는 IDE에서 never
타입으로 뜨게 됩니다.
따라서 불필요한 구문을 적지 않아도 되는 효율성을 확인할 수 있습니다.
타이핑을 부분적으로 허용하지 않는다.
type VariantA = {
a: string,
}
type VariantB = {
b: number,
}
declare function fn(arg: VariantA | VariantB): void
const input = {a: 'foo', b: 123 }
fn(input) // 타입스크립트 컴파일러는 아무런 문제도 지적하지 않음.
위의 예제를 보면 VariantA
와 VariantB
라는 타입이 존재하는데요, 우리의 목적에서 함수 fn의 매개변수는 VariantA
이거나 VariantB
여야합니다. 둘다 충족하는 조건은 우리의 목적이 아닌데요, 하지만 타입스크립트에서는 이것에 대해서 아무 문제를 제기하지 않게 됩니다. 따라서 이때 never
타입이 사용될 수 있습니다.
type VariantA = {
a: string,
b?: never,
}
type VariantB = {
a?: never,
b: number,
}
declare function fn(arg: VariantA | VariantB): void
const input = {a: 'foo', b: 123 }
fn(input) // 에러 발생
이론적으로 도달할 수 없는 분기를 표기한다.
type A = 'foo';
type B = A extends infer C ? (
C extends 'foo' ? true : false// 이 표현식 내에서 'C'는 'A'를 나타낸다.
) : never // 이 분기는 도달할 수 없지만, 생략도 할 수 없다.
위에 B의 extends infer
부분을 보면 B는 true아니면 false 둘중 하나의 값을 가지게 되기 때문에 맨 뒤의 : never
부분은 도달하지 않게 되는데요, 이럴 때 분기 표시를 해줄 때, never
타입이 사용될 수 있습니다.
유니언 타입에서 멤버 필터링
type Foo = {
name: 'foo'
id: number
}
type Bar = {
name: 'bar'
id: number
}
type All = Foo | Bar
type ExtractTypeByName<T, G> = T extends {name: G} ? T : never
type ExtractedType = ExtractTypeByName<All, 'foo'> // 결과 타입은 Foo
만약에 name
프로퍼티의 값이 'foo'
인 타입만 필터를 걸고 싶을 때, 위와 같이 필터링할 수 있습니다.
보면 T
가 {name: G}
의 서브 타입인지 확인하고, 그렇다면 그 타입을 반환하게 하고, 아닌 경우 never
를 반환하게 하고 있습니다. 위 과정은 타입 전개 과정이 섞여있기 때문에 아래와 같은 과정으로 타입 체크를 하게 됩니다.
type All = Foo | Bar
type ExtractedType = ExtractTypeByName<All, 'foo'>
// Foo extends {name: 'foo'} ? Foo : never
// Bar extends {name: 'foo'} ? Bar : never
매핑된 타입의 키 필터링
extends ? never : T
이런 문법을 사용하는 과정은 유틸리티 타입인 Omit
에서도 확인할 수 있습니다.
interface Cat {
name: string;
kind: string;
age: number;
}
type MyOmit<T, R> = {
[key in keyof T as key extends R ? never : key]: T[key];
}
const CatList: MyOmit<Cat, 'kind'> = {
name: 'kitty',
age: 5,
}
Omit
타입을 직접 구현하면 위와 같습니다. 여기서도 제네릭인 R
의 서브 타입인지 확인하고, 서브타입이 맞다면 그 속성을 지워야하니 never
타입을 넣어주고, 아닌 경우에는 그 key
를 반환하게 할 수 있습니다.
never 타입은 어떻게 검사할까?
type IsNever<T> = T extends never ? true : false
type Res = IsNever<never> // never
Res
는 true 일까요, 아님 false 일까요? 실제로 Res의 타입이 never
임에도, 값은 never
입니다.
- 왜 never가 될까?
- 타입스크립트는 조건부 타입에 대해 자동적으로 유니언 타입을 할당한다.
- never은 빈 유니언 타입이다.
- 그러므로 할당이 발생하면 할당할 것이 없으므로 조건부 타입은 never로 평가된다.
여기서 유일한 해결 방안은 암묵적 할당을 막고 타입 매개변수를 튜플에 래핑하는 것입니다.
type IsNever<T> = [T] extends [never] ? true : false;
type Res = IsNever<never> // true