Home
PostsAbout
typescript

never 타입은 사용해 본 적이 never예요

TypeScript의 never 타입에 대해 처음 알아보자

2024-10-26

never 타입의 개념

타입은 가능한 값의 집합입니다. 그리고 never 타입은 값의 공집합입니다. 공집합에는 어떤 값도 들어갈 수 없기 때문에 never 타입은 any 타입의 값을 포함해 그 어떤 값도 가질 수 없습니다.

타입스크립트 핸드북에서는 never 타입을 bottom type이라고 정의합니다. 타입 계층 트리로 이해하면 bottom type이 무엇인지 잘 와닿습니다.

never 타입을 사용하는 경우

에러를 던지는 경우

function generateError(res: Response): never {
	throw new Error(res.getMessage());
}

특정 함수가 실행 중 마지막에 에러를 던지는 작업을 수행한다면 해당 함수의 반환 타입은 never입니다.

무한히 함수가 실행되는 경우

function checkStatus(): never {
	while (true) {
		// ...
	}
}

무한 루프는 결국 함수가 종료되지 않음을 의미하기 때문에 값을 반환하지 못합니다. 따라서 해당 함수의 반환 타입은 never입니다.

Exhaustiveness Checking

// 예제 코드 출처: 우아한 타입스크립트 with 리액트
type ProductPrice = "10000" | "20000" | "5000";

const getProductName = (productPrice: ProductPrice): string => {
  if (productPrice === "10000") return "배민상품권 1만 원";
  if (productPrice === "20000") return "배민상품권 2만 원";
  else {
    exhaustiveCheck(productPrice); // Error: Argument of type 'string' is not assignable to parameter of type 'never'
    return "배민상품권";
  }
};

const exhaustiveCheck = (param: never) => {
  throw new Error("Type error!");
};

Exhaustiveness는 사전적으로 철저함, 완전함을 의미합니다. 따라서 Exhaustiveness Checking은 모든 케이스에 대해 철저하게 타입을 검사하는 것을 말하며 타입 좁히기에 사용되는 패러다임 중 하나입니다.

Exhaustiveness Checking을 사용하면 다음과 같은 장점이 있습니다.

  1. 컴파일 타임에서의 완전성 검사
    • exhaustiveCheck 함수를 사용하면 TypeScript 컴파일러가 모든 가능한 값이 처리되었는지 확인합니다. 만약 어떤 경우를 누락했다면 컴파일러는 에러를 발생시켜 개발 단계에서 이를 잡아낼 수 있습니다.
  2. 유지 보수성 향상
    • 새로운 값이 타입에 추가되면, 컴파일 타임 에러를 통해 누락된 처리를 즉시 인지할 수 있어 코드의 유지 보수가 용이합니다.
    • 위 예제 코드를 보면 운영 정책 변경으로 인해 만약 1만원권이나 2만원권 이외의 새로운 금액의 상품권이 생겼을 때, 이에 대응하지 않으면 컴파일 타임에 에러를 발생시켜 런타임에서 의도치 않은 동작이 발생하는 것을 방지할 수 있습니다.
  3. 가독성 및 명시성 증가
    • 코드를 읽는 사람에게 모든 가능한 경우를 처리하려는 의도를 명확하게 전달합니다.
    • 우아한 타입스크립트 with 리액트에서는 “우형 이야기”라는 섹션이 있는데요, 여기서 exhaustiveCheck가 런타임 코드에 포함되어 버려서 뭔가 프로덕션 코드와 테스트 코드가 같이 섞여 있는 듯한 느낌이 든다는 지적이 있었습니다.
    • 하지만 코드상의 어서션은 코드 중간중간에 무조건 특정 값을 가지는 상황을 확인하기 위한 디버깅 또는 주석 느낌으로 사용된다고 보면 다른 개발자들이 그 코드를 볼 때 가독성에 도움을 줄 것은 확실해 보입니다.

부분적으로 구조적 타이핑을 허용하지 않는 방법

type VariantA = {
	a: string;
}

type VariantB = {
	b: string;
}

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

const input = { a: 'foo', b: 123 }
fn(input);

타입스크립트는 구조적 타이핑을 기반으로 하고 있기 때문에 위와 같인 원래 타입보다 더 많은 속성을 가진 객체 타입을 함수에 전달하는 것이 허용됩니다. 이는 개발자가 의도하지 않는 방향일 확률이 크죠. 이를 never 타입을 활용해서 방지할 수 있습니다.

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

type VariantB = {
	a?: never;
	b: string;
}

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

const input = { a: 'foo', b: 123 }
fn(input); // 여기서 에러가 발생합니다.

as never 단언으로 해결할 수 있는 문제

💡 글또의 이주함, 이승환, 이정훈, 김성현a님이 도움을 주셨습니다!

다음은 TotalTypeScript라는 사이트에 등장하는 예제입니다.

const formatters = {
  string: (input: string) => input.toUpperCase(),
  number: (input: number) => input.toFixed(2),
  boolean: (input: boolean) => (input ? "true" : "false"),
};

const format = (input: string | number | boolean) => {
  const inputType = typeof input as
    | "string"
    | "number"
    | "boolean";
  const formatter = formatters[inputType];

  return formatter(input);
}

formatters 객체는 string, number, boolean 타입의 키 값이 들어오면 그에 따라 포맷팅 하는 함수를 리턴해줍니다. 또한 format 함수에서 inputType 변수는 inputstring | number | boolean 타입으로 단언한 그 타입을 값으로 가집니다. 따라서 일반적으로 생각하면 formatter는 분명히 string, number, boolean 타입 중 하나의 타입을 인자로 받을 것이라고 예상할 수 있습니다. 하지만 formatter(input) 부분에서 Argument of type 'string | number | boolean' is not assignable to parameter of type 'never'. Type 'string' is not assignable to type 'never'. 오류가 발생합니다. 그 이유는 무엇일까요?

유니언 타입에서 함수의 인자들은 각 함수 타입의 교차 집합으로 추론됩니다. 즉, 유니언 타입으로 정의된 함수는 공통적으로 적용할 수 있는 타입만 허용합니다.

inputTypestring | number | boolean 이므로 formatterformatters에 해당 유니언 타입의 원소들을 넣었을 때 나올 수 있는 모든 타입의 유니언으로 추론됩니다. 따라서 formatter의 타입은 다음과 같습니다.

((input: string) => string) | ((input: number) => string) | ((input: boolean) => "true" | "false")

따라서 inputstring & number & boolean이어야 하는데 이런 값은 없기 때문에 input의 타입이 nerver로 추론되고, string | number | boolean 타입의 input이 할당될 수 없게 됩니다. 그래서 다음과 같이 코드를 작성하면 해결할 수 있습니다.

const format = (input: string | number | boolean) => {
  const inputType = typeof input as
    | "string"
    | "number"
    | "boolean";
  const formatter = formatters[inputType];

  return formatter(input as never);
};

💡 글또 이정훈님이 제안해주신 as never 보다 좋은 방법
https://www.typescriptlang.org/play/?#code/C4TwDgpgBAYg9gJwLYENjAggPAFQHxQC8UAFAJYB2YArsAFxQ4CURBAzsApQOYDcAUPwDGcChygAzRKnSY2DAN78oUDlwrcG8ZGgzY1PPAJUVqSAEaYt03ZiymLmI8qjm4cADYQUFaztnYbp7eFM4AvkRQSioGGgzkVLQshASUNMAAdMBwAKpgkAgAwihsECRMADQuDpYI8WlJrFANmdkwZAAeEAAmJABMlS5BXj71icDJBAnpUAD8UABEnNQQC1AMCxIoHqULg2EC-AD0R4Ch44CkHVCA2D2AIuNQgDKtgAG9gAyLUIA4E4A6q4AeNYAMdcKi4ik-gAjJFcFAIB0MBRumxVJweFAAD5QGqYZGudwjULTWgMZgMWLcJrRKAiMTAZrjHDgaDEUCQOASKnpQ4qMjM3HAGmQIiEYgLIl7KIuFQICDAagICiSGwBNgZIlcqAleHqbhMYxQCIQHbQDmkFo8un8xZohDC0liiVSmVAmR6BXm5Wq82alw6vXNTlG2l8gXDEKW0VQcWS6Wy-yOjKBnwuuGxijulRhQQqYAACwQcAA7qiIHmAKIIbMIEgLACSFAAbtsyN0WbQoAzVu6DoIToABhdOgAAawA7LYAZzqggBumv7kwFyvqRLmEhEaDHmjGJpiz9UklwGkgtpmNyn8gVClhW0M2iP22wIBVKlqaqAeiFezfb5ktf1msy1YMqa3hu1y6POjevB3imD6lN6pDPrub4LImX7fmGtqRg6cgxliIRcreHpps2Wa5vmRYlog5ZVrWHj1tBLZ7AIBxAA

never 타입을 확인하는 방법

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

type Res = IsNever<never> // never 🧐

never 타입인지를 확인하는 것은 쉽지 않은데요. https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379094672 에 그 내용이 적혀있습니다. 요약하자면 never 는 빈 유니온 타입이고, 타입스크립트는 조건부 타입 내부에 있는 유니온 타입을 자동으로 결정하는데, 이때 never 가 들어왔으므로 조건 타입이 never 가 됩니다.

따라서 never 임을 판단하려면 타입스크립트 공식 예제에서 제공하는 다음과 같은 코드로 판단할 수 있습니다.

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

참고 자료

https://www.totaltypescript.com/as-never

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

https://witch.work/posts/typescript-never-type

https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#other-important-typescript-types

https://www.zhenghao.io/posts/type-hierarchy-tree#the-bottom-of-the-tree