sbyeol3/articles

[번역] 타입스크립트 Never 완벽 가이드

Opened this issue · 0 comments

원문 : https://www.zhenghao.io/posts/ts-never

타입스크립트의 never는 다른 타입들만큼 아주 흔하거나 무시할 수 없는 타입이 아니기 때문에 그다지 활발하게 논의되는 타입이 아닙니다. 심화된 타입을 다루거나 까다로운 타입 에러 메시지를 읽을 때나 등장하기 때문에 초급자들은 아마 never 타입을 무시하고도 타입스크립트를 쓸 수 있습니다.

never 타입은 타입스크립트에서 꽤 좋은 유즈 케이스를 가지고 있습니다. 그러나, 그만큼 여러분들이 조심해야 하는 위험들도 갖고 있습니다.
이 글에서는 다음 내용을 다룹니다.

  • never 타입의 의미와 필요성
  • never 타입의 응용과 위험
  • 여러 말장난들 🤣

never 타입이 무엇일까?

never 타입과 이것의 목적을 완벽하게 이해하기 위해서는, 타입이란 무엇인지 그리고 타입 시스템에서 타입이 어떤 역할을 하는지 먼저 이해해야 합니다.
타입은 가능한 값들의 집합과 같습니다. 예를 들어 string 타입은 가능한 문자열의 무한집합을 나타냅니다. 우리가 변수에 string 타입이라고 붙여줄 때, 그 변수는 문자열 집합 내에서만 값을 가질 수 있게 됩니다.

let foo: string = 'foo'
foo = 3 // ❌ 숫자는 문자열 집합에 속하지 않습니다.

타입스크립트에서 never는 값들의 공집합을 의미합니다. 실제로 또다른 유명한 자바스크립트 타입 시스템인 Flow에서도 never와 동일한 타입을 empty라고 부른답니다.

집합에 값이 없기 때문에 neverany 타입을 포함하여 절대로(never ㅋ) 어떠한 값도 가질 수 없습니다. never가 때때로 거주 불가능한 타입(uninhabitable type) 또는 바닥 타입(bottom type)이라고 불리는 이유입니다.

declare const any: any
const never: never = any // ❌ 'any' 타입은 'never'에 할당할 수 없습니다.

바닥 타입은 TypeScript Handbook에서 정의한 단어입니다. 저는 서브타이핑을 이해하는 데 사용하는 타입 계층 트리에서 never를 두지 않는 게 더 이해하기 쉽다고 생각했습니다.

다음 논리적인 질문은, '왜 never 타입이 필요한가?' 입니다.

왜 우리는 never 타입이 필요할까?

숫자 시스템에서 아무것도 가지지 않았다는 것을 나타내기 위해 우리가 0을 사용하는 것처럼, 타입 시스템도 불가능함을 나타내는 타입이 필요합니다. "불가능함"이란 단어 자체가 모호합니다. 타입스크립트에서 "불가능함"은 다양한 방식으로 나타납니다.

  • 다음을 나타내기 위해 사용될 수 있는, 어떠한 값도 가지지 않는 빈 타입
    • 제네릭과 함수에서 허용되지 않는 파라미터
    • 서로 배타적인 타입들의 교칩합
    • 비어있는 합집함 (없음의 합집합)
  • 실행이 완료되었을 때 호출자(caller)에게 제어를 아무것도(never) 반환하지 않는 함수의 리턴 타입 (Node에서 process.exit처럼)
    • void와 혼동하지 마세요. void는 호출자에게 유용한 무언가를 리턴하지 않는다는 의미입니다.
  • 조건문에서 절대(never) 들어갈 수 없는 else 분기
  • reject된 promise의 fulfilled된 값의 타입
const p = Promise.reject('foo') // const p: Promise<never>

never 타입은 합집합(union)과 교집합(intersection)에서 어떻게 동작할까?

덧셈, 곱셈에서 0이 동작하는 방식과 유사하게, never 타입은 union 타입과 intersection 타입에서 사용되는 특별한 프로퍼티를 가집니다.

  • never와 합집합을 하게 되면 never는 제외됩니다. 어떤 숫자에 0을 더했을 때 그 숫자가 그대로인 것과 같은 원리입니다.
    • 예시) type Res = never | string // string
  • never와 intersection 하게 되면 다른 타입을 오버라이딩합니다. 어떤 숫자에 0을 곱하면 0이 되는 것처럼 말입니다.
    • 예시) type Res = never & string // never

never 타입이 가지는 두 가지 동작/특성은 나중에 보게 될 몇몇 가장 중요한 유즈 케이스의 기반을 마련해줍니다.

어떻게 never 타입을 사용할까?

아마 여러분들은 스스로 never를 많이 사용하진 않았겠지만, 이미 꽤 잘 사용되는 유즈 케이스들이 존재합니다.

허용되지 않는 함수 매개변수를 제한하기

어떤 값에 never 타입을 할당할 수 없기 때문에 우리는 다양한 응용을 위해 함수에 제한을 가하는 방식으로 never를 사용할 수 있습니다.

switch문과 if-else문에서 완전히 일치하는지 보장

어떤 함수가 오직 never 타입인 하나의 매개변수만 받는다면 그 함수는 never가 아닌 값과 함께 호출될 수 없습니다.

function fn(input: never) {}

// `never`만 허용
declare let myNever: never
fn(myNever) // ✅

// 다른 걸 전달하면 타입 에러 발생
fn() // ❌ 'input`에 대한 인자가 주어지지 않음.
fn(1) // ❌ number 타입은 never에 할당할 수 없음
fn('foo') // ❌ string 타입은 never에 할당할 수 없음

// 심지어 any도 불가능
declare let myAny: any
fn(myAny) // ❌ any 타입은 never에 할당할 수 없음

이러한 기능을 사용하여 switch문과 if-else문에서 완전히 일치하도록 보장할 수 있습니다. 남은 타입은 반드시 never 타입이므로 default 케이스로 never 타입을 사용하면 모든 경우가 커버될 수 있기 때문입니다. 실수로 일치하는 항목을 누락하면 타입 에러가 발생합니다.

function unknownColor(x: never): never {
    throw new Error("unknown color");
}


type Color = 'red' | 'green' | 'blue'

function getColorName(c: Color): string {
    switch(c) {
        case 'red':
            return 'is red';
        case 'green':
            return 'is green';
        default:
            return unknownColor(c); // Argument of type 'string' is not assignable to parameter of type 'never'
    }
}

구조적인 타이핑을 부분적으로 비허용

VariantA 또는 VariantB를 가지는 파라미터를 받는 함수가 있다고 가정해봅시다. 그러나 사용자가 두 유형의 모든 프로퍼티를 포함하는 타입을 전달해서는 안되는 경우입니다.

파라미터로 유니온 타입인 VariantA | VariantB을 사용할 수 있습니다. 그러나 타입스크립트에서 타입 호환성은 구조적인 서브타이핑을 기반으로 하고 있기 때문에 함수의 파라미터의 타입보다 더 많은 프로퍼티를 가지는 객체를 전달하는 것은 허용되지 않습니다. (객체 리터럴을 전달하지 않는 한)

type VariantA = {
    a: string,
}

type VariantB = {
    b: number,
}

declare function fn(arg: VariantA | VariantB): void

const input = {a: 'foo', b: 123 }
fn(input) // TypeScript가 안된다고 하진 않지만 우리는 이를 허용하지 않아야 합니다.

위의 코드 스니펫에서 타입스크립트는 타입 에러를 발생시키지 않습니다.
never를 사용함으로써 부분적으로 구조적 타이핑을 불가능하게 하고 사용자들이 프로퍼티를 다 가지는 객체를 전달하지 못하게 할 수 있습니다.

type VariantA = {
    a: string
    b?: never
}

type VariantB = {
    b: number
    a?: never
}

declare function fn(arg: VariantA | VariantB): void

const input = {a: 'foo', b: 123 }
fn(input) // ❌ Types of property 'a' are incompatible

의도되지 않은 API 사용 방지

데이터를 읽고 저장하는 Cache 인스턴스를 생성한다고 해봅시다.

type Read = {}
type Write = {}
declare const toWrite: Write

declare class MyCache<T, R> {
  put(val: T): boolean;
  get(): R;
}

const cache = new MyCache<Write, Read>()
cache.put(toWrite) // ✅ allowed

어떠한 이유든지 간에 get 메소드를 통해 데이터를 읽기만 가능한 read-only 캐시를 가지고 싶어질 수도 있습니다. put 메소드 인자에 never 타입을 추가하게 되면 함수 내부로 어떠한 값이든 받을 수 없게 됩니다.

declare class ReadOnlyCache<R> extends MyCache<never, R> {} 
                        // Now type parameter `T` inside MyCache becomes `never`

const readonlyCache = new ReadOnlyCache<Read>()
readonlyCache.put(data) // ❌ Argument of type 'Data' is not assignable to parameter of type 'never'.

부가적으로 never 타입과는 관련없지만, 이는 파생 클래스의 좋은 예시가 아닐 수 있습니다. 저는 객체 지향 프로그래밍의 전문가가 사실 아니기 때문에 각자 알아서 판단해주시길 바랍니다.

이론적으로 닿을 수 없는 조건 분기들을 나타내기

조건 타입 내에서 추가적인 타입 변수를 생성하기 위해 infer를 사용할 때 우리는 모든 infer 키워드에 반드시 else 분기를 추가해야 합니다.

type A = 'foo';
type B = A extends infer C ? (
    C extends 'foo' ? true : false// inside this expression, C represents A
) : never // 이 분기점에 닿을 수 없겠지만 생략할 순 없습니다

extends infer 콤보가 유용한가요?
이전에 작성한 글에서 "로컬 (타입) 변수"를 선언하는 방식과 함께 extends infer를 언급했었습니다. 아직 읽지 않았다면 여기를 참고하세요.

유니온 타입에서 유니온 멤버 분리하기

불가능한 분기를 나타내는 것 외에도, never는 조건 타입에서 원치않는 타입을 분리해내는 데 사용할 수 있습니다.
이전에도 얘기했듯이, 유니온 멤버로써 never를 사용하면 자동적으로 제거된다고 했습니다. 즉, 유니온 타입에서 never는 쓸모가 없는 것이죠.

어떤 기준점을 기반으로 유니온 타입에서 유니온 멤버를 선택하는 유틸리티 타입을 작성할 때 never 타입의 쓸모없음은 else 분기에 완벽한 타입을 두게끔 만들어줍니다.

foo 문자열 리터럴을 가지는 name 프로퍼티를 추출하고, 그 외에는 필터링하는 유틸리티 타입인 ExtractTypeByName을 작성한다고 해볼까요.

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'> // the result type is Fo

실제로 어떻게 동작하는지 자세히 살펴봅시다.
다음은 타입스크립트가 결과 타입을 평가하고 가져오는 각 단계들입니다.

  • 조건부 타입은 유니온 타입에 걸쳐 분산됩니다.
type ExtractedType = ExtractTypeByName<All, Name> 
⬇️                    
type ExtractedType = ExtractTypeByName<Foo | Bar, 'foo'>
⬇️    
type ExtractedType = ExtractTypeByName<Foo, 'foo'> | ExtractTypeByName<Bar, 'foo'>
  • 구현을 대체하고 개별적으로 평가됩니다.
type ExtractedType = Foo extends {name: 'foo'} ? Foo : never 
                    | Bar extends {name: 'foo'} ? Bar : never
⬇️
type ExtractedType = Foo | never
  • 유니온에서 never를 제거합니다.
type ExtractedType = Foo | never
⬇️
type ExtractedType = Foo

매핑된 타입에서 key를 필터링하기

타입스크립트에서 타입은 불변입니다. 객체 타입에서 어떤 프로퍼티를 제거하고자 한다면 이미 존재하는 타입을 변형하고 필터링하여 새로운 타입을 만들어야만 합니다. 매핑된 타입의 키를 조건부로 다시 never로 매핑할 때, key는 필터링됩니다.

객체의 값 타입에 따라 객체 타입 프로퍼티를 필터링하는 Filter 타입에 대한 예시입니다.

type Filter<Obj extends Object, ValueType> = {
    [Key in keyof Obj 
        as ValueType extends Obj[Key] ? Key : never]
        : Obj[Key]
}

interface Foo {
    name: string;
    id: number;
}

type Filtered = Filter<Foo, string>; // {name: string;}

제어 흐름 분석에서 타입 좁히기

함수의 리턴 값을 never라고 타입을 지정한다는 것은, 함수가 실행이 종료되었을 때 절대 호출자에게 제어권을 리턴해주지 않는다는 것을 의미합니다. 타입을 좁히기 위한 제어 흐름 분석에서 이 점을 응용할 수 있습니다.

함수는 다음과 같은 이유로 아무것도 리턴하지 않을 수 있습니다: 모든 코드 경로마다 예외를 throw하는 경우, 무한 루프에 빠지는 경우, process.exit과 같은 프로그램에 의해 종료되는 경우

다음 코드 스니펫에서, never 타입을 리턴함수를 사용하여 foor라는 union 타입에서 undefined를 제거할 수 있습니다.

function throwError(): never {
    throw new Error();
}

let foo: string | undefined;

if (!foo) {
    throwError();
}

foo; // string

또는 ||?? 연산자 이후에 throwError를 호출할 수도 있습니다.

let foo: string | undefined;

const guaranteedFoo = foo ?? throwError(); // string

공존할 수 없는 타입들의 불가능한 교집합 나타내기

never의 응용 사례보다는 타입스크립트 언어의 동작/특징에 더 가깝다고 느껴질 수도 있습니다. 그럼에도, 이 점은 여러분이 마주칠 수도 있는 몇 가지 암호 에러 메시지를 이해하는 데 아주 중요합니다.

공존할 수 없는 타입들끼리 교집합 연산을 하면 never 타입을 얻을 수 있습니다.

type Res = number & string // never

또한, 어느 타입이든지 never로 교집합 연산을 하면 never를 얻겠죠.

type Res = number & never // never
이건 객체 타입에서 더 복잡합니다.
객체 타입을 교차하게 되면, 프로퍼티 타입이 판별 프로퍼티인지 아닌지에 따라(리터럴 유형 또는 리터럴 유형의 유니온 포함), 전체 타입을 `never`로 좁힐 수도 있고 아닐 수도 있습니다. 이 예시에서 오직 `name` 프로퍼티만 `never`타입이 됩니다. `string`과 `number`는 공존할 수 없기 때문이죠
type Foo = {
    name: string,
    age: number
    }
type Bar = {
    name: number,
    age: number
}

type Baz = Foo & Bar // {name: never, age: number} 

다음 예시에서는 전체 타입인 Baznever로 좁혀지는데 boolean은 판별 프로퍼티이기 때문입니다. (true | false의 유니온)

type Foo = {
    name: boolean,
    age: number
    }
type Bar = {
    name: number,
    age: number
}

type Baz = Foo & Bar // never

PR에서 더 살펴보세요.

(에러 메시지로부터) never 타입을 읽는 방법

여러분이 명시적으로 never라고 타입을 지정하지 않으면, 예상치 못한 never 타입과 함께 에러 메시지를 마주하게 될 수도 있습니다. 이는 기본적으로 타입스크립트 컴파일러가 타입들을 교차하기 때문입니다. 타입의 안전성을 지키고 건전성(soundness)을 보장하기 위해 암묵적으로 수행되는 작업입니다.

다음 예시는 이전에 다형적인 함수의 타입에 대해 작성한 포스팅에서 사용한 예시입니다.

type ReturnTypeByInputType = {
  int: number
  char: string
  bool: boolean
}

function getRandom<T extends 'char' | 'int' | 'bool'>(
  str: T
): ReturnTypeByInputType[T] {
  if (str === 'int') {
    // generate a random number
    return Math.floor(Math.random() * 10) // ❌ Type 'number' is not assignable to type 'never'.
  } else if (str === 'char') {
    // generate a random char
    return String.fromCharCode(
      97 + Math.floor(Math.random() * 26) // ❌ Type 'string' is not assignable to type 'never'.
    )
  } else {
    // generate a random boolean
    return Boolean(Math.round(Math.random())) // ❌ Type 'boolean' is not assignable to type 'never'.
  }
}

이 함수는 전달받는 인자에 따라 숫자, 문자열, 불리언을 리턴합니다. 리턴 값에 해당하는 타입을 가져오기 위해 ReturnTypeByInputType[T]를 사용합니다. 그러나 모든 리턴문에서 Type X is not assignable to type 'never'라는 타입 에러가 발생합니다. 분기에 따라 X는 string, number, boolean이 됩니다.

타입스크립트는 우리 프로그램이 문제를 일으킬 가능성을 줄여주기 위해 도움을 주려고 하기 때문에 발생합니다. 각 리턴 값은 ReturnTypeByInputType[T]에 할당할 수 있어야 하는데, 이 값은 런타임 때 number, string, boolean이 될 수 있습니다.

리턴 타입이 모든 가능한 ReturnTypeByInputType[T]에 할당할 수 있다고 보장할 수 있을 때 타입 안정성이 지켜집니다. 즉 number, string, boolean의 intersecion일 때가 그런 경우지만 이는 서로 공존할 수 없는 타입이기 때문에 never가 되는 것이죠. 그래서 에러 메시지에 never를 보게 되는 겁니다.

이를 해결하려면, 여러분은 타입 단언(type assertions) (또는 함수 오버로딩)을 사용해야만 합니다.

  • return Math.floor(Math.random() * 10) as ReturnTypeByInputType[T]
  • return Math.floor(Math.random() * 10) as never

다른 분명한 예시가 있을 수 있습니다.

function f1(obj: { a: number, b: string }, key: 'a' | 'b') {
    obj[key] = 1;    // Type 'number' is not assignable to type 'never'.
    obj[key] = 'x';  // Type 'string' is not assignable to type 'never'.
}

obj[key]는 런타임에 key의 값에 따라 string 또는 number가 됩니다. 이러한 제약조건이 더해진 타입스크립트는 타입의 안정성을 위해 string과 number 두 타입 모두 공존가능한 값만 쓰게끔 합니다. 그래서 두 타입의 교차된 타입인 never 타입이 전달되는 것입니다.

never 확인하기

어떤 타입이 never이어야 하는지 확인하는 것보다 never인지 확인하는 것이 더 어렵습니다. 다음 코드 예시를 봅시다.

type IsNever<T> = T extends never ? true : false

type Res = IsNever<never> // never 🧐

Restrue 또는 false가 될까요? 놀랍게도 둘 다 정답이 아닙니다. 실제로는 never가 됩니다.

저도 이걸 처음 접했을 때 정신이 나가는 듯 했습니다. Ryan Cavanaugh여기서 이를 설명해주었습니다.

요약을 해보자면 다음과 같습니다.

  • 타입스크립트는 조건부 타입을 유니온 타입으로 자동적으로 바꿉니다.
  • never는 빈 유니온입니다.
  • 그래서 아무것도 합칠 게 없기 때문에 조건부 타입은 다시 never가 됩니다.

여기서 유일한 해결책은 암시적으로 합치는 것을 선택하고 튜플로 타입 매개변수를 감싸는 것입니다.

type IsNever<T> = [T] extends [never] ? true : false;
type Res1 = IsNever<never> // 'true' ✅
type Res2 = IsNever<number> // 'false' ✅

이는 실제로 타입스크립트 소스 코드의 일부이며 타입스크립트가 이 코드를 외부에 노출시키면 더 좋을 듯 합니다.

요약

이 글에서 정말 많은 것을 다루었네요.

  • 먼저, never 타입의 정의와 목적에 대해 다뤘습니다.
  • 그리고 다양한 사례들을 살펴봤습니다.
    • never가 빈 타입인 점을 활용하여 함수에 제약사항 추가하기
    • 필요없는 유니온 멤버와 객체 프로퍼티를 필터링하기
    • 제어 흐름 분석에 안전성을 더하기
    • 유효하지 않거나 불가능한 조건부 분기를 나타내기
  • 암묵적인 타입 교차때문에 타입 에러메시지에서 갑자기 never가 나타나는 이유에 대해서도 다뤘었죠
  • 마지막으로 never 타입인지 확인하는 방법까지 살펴봤습니다.

이 글을 리뷰해주고 아주 소중한 피드백을 남겨준 친구 Josh에게 감사의 인사를!