never 타입에 대한 정리

Never 타입 가이드

타입스크립트에 타입들 중 Never 라는 타입이 있습니다. 흔하게 사용되는 타입은 아니며, 상황에 따라 잘 사용해야 하는 타입으로 간주 됩니다. 저 또한 현업에서 간혹 사용하는데 어떻게 활용되고, 무엇을 조심해야 하는지를 알아보죠.

오늘 다룰 내용

  • never의 의미와 필요성
  • never 실 사용과 함정

Never 타입이란?

never 타입은 불가능을 나타내는 타입입니다. any 타입 하위에도 포함되지 않는 독립적인 타입입니다.

let nev: string = 'naver'
nev = 10 // error

declare const any: any
const nev: never = any // error

Never 타입은 일반적으로 함수의 리턴 타입으로 사용합니다. 함수의 return type이 never로 되면, 항상 오류를 출력하거나 return 값을 내보내지 않음을 의미하게 됩니다.

// 항상 오류를 반환
function returnNever(message:string): never {
	throw new Error(message);
}

// never 타입을 결과 추론
function callNever() {
	return returnNever('Never');
}

Never가 등장하게 된 배경

1. 자동으로 never 타입을 가지는 경우

function func() {  // -- 함수 선언문

}

let func2 = function () { // -- 함수 표현식

}

함수 선언문과 함수 표현식은 자바스크립트에서 함수를 만드는 방법이다.

함수 선언문 같은 경우 아무것도 return 하지 않고 끝나는 경우 void 타입이 자동으로 return 된다.

함수 표현식은 아무것도 return 하지 않는 경우 never 타입이 자동으로 return 된다.

2. 파라미터가 never 타입이 되는 경우

function func(parameter: string) {
	if (typeof parameter === 'string') {
		parameter + '1';
	} else {
		parameter;
	}
}

위 코드는 typeof를 통해 파라미터의 타입이 string이면 parameter 뒤에 1를 추가하는 코드 입니다.

여기서 else 문이 존재하는데 parameter의 type이 string이기 대문에 else문은 탈 수 없습니다.

그렇기 때문에 여기서는 파라미터의 타입이 never로 변하게 된다.

이런 경우 코드를 수정하는 것이 좋다.

Never 타입은 왜 사용할까?

숫자 체계를 보면 아무것도 없는 값을 의미하는 0이란 값을 사용합니다. 다른 타입에서도 이와 같은 불가능을 나타내는 타입이 필요합니다.

여기서 이야기하는 불가능이란 뜻이 모호합니다. 불가능은 아래와 같은 의미를 담고 있습니다.

  1. 값을 포함할 수 없는 빈 타입
    • 제네릭과 함수에서 허용되지 않는 매개변수
    • 호환되지 않는 타입들의 교차 타입
    • 빈 합집합(무의 합집합)
  2. 실행이 끝날 때 호출자에게 제어를 반환하지 않는 함수의 반환 타입
    • process.exit와 같은 함수
    • void와는 다른 뜻입니다. void는 반환할 값이 없다라는 뜻입니다.
  3. 절대로 도달 할 수 없는 else 분기 조건 타입
  4. 거부된 promise에서 처리된 값의 타입 등
const p = Promise.reject('poo'); const p: Promise<never>

유니언/교차 타입과 never의 동작

숫자 0이 덧셈과 곱셈에 작용 방법과 유사하듯이, never 타입은 유니언/교차 타입에서 특별한 속성을 지닌다.

  • never 타입은 유니언 타입에서 없어진다.
type Res = never | string // string
  • never 타입은 교차 타입을 덮어쓴다.
type Res = never & string // never

never 타입은 어떻게 활용할까?

never 타입을 사용하는 경우는 많지 않습니다. 사용하는 대표 사례를 소개합니다.

허용할 수 없는 함수 매개변수에 제한을 가한다.

never 타입을 이용해서 다양한 사용 사례에 놓인 함수에 제안을 걸 수 있다.

switch, if-else 문의 모든 상황을 보장한다.

함수가 단 하나의 never 타입 인수만을 받을 수 있는 경우, (타입스크립트 컴파일러가 오류를 발생하지 않고는) 해당 함수를 never 타입 이외의 값으로 호출 할 수 없습니다.

function fn(input: never) {}

declare let myNever: never
declare let myAny: any

fn(myNever)

// 아무 값이나 전달하거나 아무 값도 전달하지 않으면 타입 에러 발생
fn() // 인자 'input'에 아무 값도 주어지지 않음
fn(1) // 'number' 타입은 'never' 타입에 할당할 수 없음
fn('foo') // 'string' 타입은 'never' 타입에 할당할 수 없음
fn(myAny) // 'any' 타입은 'never' 타입에 할당할 수 없음

이런 함수를 이용하면 switch, if-else 문의 모든 상황을 보장할 수 있다. 이를 기본 케이스(default case)로 이용하면 남아있는 것은 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); // 'string' 타입은 'never' 타입에 할당할 수 없음
    }
}

타이핑을 부분적으로 허용하지 않는다.

매개변수를 받는 한 함수는 사용자가 두 타입의 모든 속성을 모두 포함하는 하위 타입을 전달해서는 안된다.

이런 조건일 때 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) // 속성 'a'의 타입은 호환되지 않는다.

의도하지 않은 API 사용을 방지한다.

의도하지 않은 API 사용을 방지하는 것은 아래 코드와 같은 상황을 이야기한다.

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) //  허용

위 코드는 캐시 인스턴스를 생성하는 코드이다. 보면 Read와 Write 두 가지 타입을 사용한다.

여기서 오직 읽기 전용 캐시 인스턴스를 생성한다고 가정해보자.

그렇다면 위 코드는 어떻게 사용해야 할까?

declare class ReadOnlyCache<R>; extends MyCache<never, R> {}

const readonlyCache = new ReadOnlyCache<Read>()
readonlyCache.put(data) // 'Data' 타입의 인자는 'never' 타입의 매개변수에 할당될 수 없다.

위와 같이 never를 사용함으로써 T에 들어갔던 Write 함수인 Put를 막을 수 있게 된다.

이론적으로 도달할 수 없는 분기를 표기한다.

infer를 사용해 조건부 타입 내에 추가 타입 변수를 생성할 경우 모든 infer 키워드에 대해 else 분기를 추가해야 한다.

type A = 'foo';
type B = A extends infer C ? (
    C extends 'foo' ? true : false // 이 표현식 내에서 'C'는 'A'를 나타낸다.
) : never // 이 분기는 도달할 수 없지만, 생략도 할 수 없다.

유니언 타입에서 멤버 필터링

never 타입은 조건부 타입에서 원하지 않는 타입을 필터링 할 수 있다.

never 타입은 유니언 타입에서 자동으로 제거된다. 그렇기 대문에 특정 기준에 따라 유니언 타입에서 멤버를 선택하는 유틸리티 타입을 작성할 대, never 타입은 쓸모가 없기 대문에 else 분기에 배치하기에 완벽한 타입이다.

아래 코드를 보면 좀 더 이해하기 쉽다.

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

위 코드는 동작 방식을 아는 것이 중요하다.

  • 조건부 타입은 유니언 타입에 걸쳐 할당된다.
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

이런 식으로 타입이 평가되고 할당됩니다.

매핑된 타입의 키 필터링

타입스크립트에서 타입은 불변입니다. 객체 타입에서 한 속성을 제거하고 싶다면 기존 타입을 변환하고, 필터링을 통해 새로운 타입을 만들어야 합니다. never를 사용하면 필터링을 할 수 있습니다.

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로 설정하면 함수는 실행이 끝났을 때 제어권을 호출자에게 반환하지 않습니다. 이를 이용하면 흐름 분석을 제어해서 타입을 좁힐 수 있죠.

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

let foo: string | undefined;

if (!foo) {
    throwError();
}

foo; // string
let foo: string | undefined;

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

위 코드는 never 타입을 통해 undefined 타입 반환을 제거하는 코드들입니다.

위와 같은 방법으로 타입을 좁힐 수 있죠.

호환되지 않는 타입의 불가능한 교차 타입 표시

호환되지 않는 타입을 교차해서 never 타입을 얻을 수 있습니다.

type Res = number & string // never

그리고 아무 타입과 never 타입을 교차해서 never 타입을 얻을 수 있습니다.

type Res = number & never // never

신기한 점은 객체를 교차하게 되면 더욱 복잡하게 변경된다.

type Foo = {
    name: string,
    age: number
}

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

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

위 코드를 보면 string과 number 같은 객체는 교차되지 않는 속성에 대해서만 never가 된다.

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

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

type Baz = Foo & Bar // never

하지만 위 코드에서는 객체 전체가 never가 된다. 이유는 name이 boolean 형인데 Bar에 있는 name은 number이기 때문에 교차하면 객체 전체가 never로 축소된다.

never 타입은 어떻게 읽을까?

명시적으로 never를 사용하지 않은 코드에서 never 타입과 관련된 오류 메시지를 받아본 적이 있을 수 있습니다. 이는 타입스크립트가 일반적으로 타입을 교차하기 때문입니다.

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) // 'number' 타입은 'never'타입에 할당할 수 없다.
  } else if (str === 'char') {
    // generate a random char
    return String.fromCharCode(
      97 + Math.floor(Math.random() * 26) // 'string' 타입은 'never'타입에 할당할 수 없다.
    )
  } else {
    // generate a random boolean
    return Boolean(Math.round(Math.random())) // 'boolean' 타입은 'never'타입에 할당할 수 없다.
  }
}

위 코드는 전달하는 인수 타입에 따라 알맞는 타입을 반환합니다. 그러나 실행해보면 오류가 발생합니다.

return type이 가능한 모든 ReturnTypeByInputTyue[T] xㅏ입에 할당이 가능해야 타입이 안정하다고 할 수 있습니다. 그렇기 때문에 위 코드의 if 문 안의 코드들은 오류가 발생하며 never 타입을 반환하게 됩니다.

에러가 발생하지 않게 하기 위해서는 아래 코드처럼 사용해야 합니다.

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

이렇게 사용하면 문제 없이 return 하게 됩니다.

never 타입은 어떻게 검사할까?

타입이 never인지 아닌지는 어떻게 알 수 있을까요?

아래 코드를 보죠

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

type Res = IsNever<never> // never

위 코드를 이해하기 위해서는 아래 전제를 알고 있어야 합니다.

  • 타입스트립트는 조건부 타입에 대해 자동적으로 유니언 타입을 할당합니다.
  • never는 빈 유니언 타입입니다.
  • 할당이 발생하면 할당할 것이 없는 타입으로 조건부 타입은 never로 평가됩니다.

그러면 위 코드를 해결하기 위해서는 아래 코드처럼 해야 합니다.

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

정리

never는 TypeScript가 가진 다양한 타입 중 하나입니다.

사용처는 적지만 잘 알고 있다면 활용하기 좋은 타입입니다.

정리하면 아래와 같은 사용 예시가 있습니다.

  • never 타입이 빈 타입이라는 것을 활용해 기능 제약 수행
  • 원하지 않는 유니언 타입의 멤버 및 객체 타입의 속성 필터링
  • 제어 흐름 분석 지원
  • 유효하지 않거나 도달할 수 없는 분기 표시

사용 예시를 잘 보고 실 개발에서 잘 사용하면 좋겠습니다.

참고자료

https://ui.toast.com/posts/ko_20220323/

https://bum-developer.tistory.com/entry/TypeScript-never-타입

https://yamoo9.gitbook.io/typescript/types/never

다른 글

http://centbin.com/nodejs-%eb%b0%b1%ec%97%94%eb%93%9c%eb%9e%80-v8%ec%97%94%ec%a7%84%ea%b3%bc-%ec%9d%b4%eb%b2%a4%ed%8a%b8-%eb%a3%a8%ed%8a%b8/

Leave a Comment