main

타입스크립트의 Never

Articles
12

오늘은 타입스크립트의 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) // 타입스크립트 컴파일러는 아무런 문제도 지적하지 않음.

위의 예제를 보면 VariantAVariantB라는 타입이 존재하는데요, 우리의 목적에서 함수 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

Reference Doc

타입스크립트의 Never 타입 완벽 가이드


김다은 이모지
Daeun Kim
Junior Frontend Engineer