kimjeongwonnabout

NoInfer 알아보기

제네릭의 타입 추론 진정제

Typescript 5.4 beta가 출시되었고 그에 따라서 블로그 포스팅이 올라왔다. 그 중에서 NoInfer라는 이름의 유틸리티 타입이 추가된 게 눈에 띄었는데, 제법 유용하고 자주 사용될 유틸리티 타입이라는 생각이 들어서 정리해봤다. 위 포스팅에서도 잘 설명되어 있지만 간단하게 알아보자.

No Inference

직역하면 '추론하지 마세요’겠다. 말 그대로 타입 추론을 하지 못하게 하는 유틸리티 타입인데, 타입을 작성하다보면 타입을 미리 결정하지 못하고 추론을 통해서 결정해야 하는 상황이 있을 때 보통 제네릭을 활용하여 해결한다. 예를 들어 아래와 같은 상황이 있다.

function pick<T>(collection: readonly T[], item: string): T {
  return collection.find((_item) => _item === item);
}

배열의 요소를 하나 가져오는 함수이다. 그런데 이렇게 하게 된다면 가져오는 아이템의 타입이 너무 열려있어서 좋지 않다. collection의 타입에 따라 item의 타입이 결정될 수 있도록 하고싶은 의도로 코드를 다음과 같이 수정할 수 있다.

function pick<T>(collection: readonly T[], item: T): T {
  return collection.find((_item) => _item === item);
}

그러나 이렇게 타입을 선언하고 실제 사용하게 되면 예상과는 다르게 동작하는 걸 알 수 있다.

const fruits = ["apple", "banana", "tomato"] as const;
const item = pick(fruits, "pineapple");
//    ^
//    'apple' | 'banana' | 'tomato' | 'pineapple'

pick의 두 번째 인자가 fruits의 요소가 아닌데도 타입에러를 발생시키지 않고 같이 추론의 대상이 되는 문제가 발생한다. 순서대로 첫 번째 인자 T가 추론되어 타입이 결정되고 두번째 인자의 타입은 결정된 타입으로 처리될 거라 기대했지만, 두 번째 인자에서도 동일한 제네릭 T가 사용되기 때문에 함수가 호출될 때 제네릭의 구조가 만족할 수 있는 결과로 추론되어 원하지 않는 상황이 발생했다.

하지만 타입스크립트로 개발을 하다보면 이미 이런 상황들을 많이 겪었고 다음과 같은 방법들을 통해서 해결할 수 있다는 걸 이미 알고 있다.

// 제네릭을 하나 더 추가하고 첫 번째 제네릭의 하위 타입으로 만든다
function pick<T, I extends T>(collection: readonly T[], item: I): T;

// 호출 할 때 먼저 제네릭을 확정한다
pick<typeof fruits[number]>(fruits, "pineapple");

보통은 첫 번째 방법을 더 활용하겠지만, T타입의 추론을 막기위해서 제네릭을 하나 더 사용해야 하고 I타입은 오직 T타입의 참조를 위해서만 사용되기 때문에 합리적인 방법은 아니다. 이쯤 되면 NoInfer유틸리티 타입의 역할이 추론될 것이다. NoInfer유틸리티 타입을 사용하면 아래와 같이 합리적이고 간단한 방법으로 처리할 수 있다.

function pick<T>(collection: readonly T[], item: NoInfer<T>): T;

포스팅 한 이유

최근 Solid.js의 내부 구현이 궁금해서 소스코드를 뒤져보다가 NoInfer 타입을 직접 구현해서 사용하고 있었던 걸 보고 신기하고 유용하다 생각하고 있었는데, 며칠 뒤에 타입스크립트 5.4 베타에 포함될 거라는 이야기를 듣고 반가워서 정리 해 봤다.

만약 NoInfer유틸리티 타입을 미리 사용해 보고 싶다면 아래와 같이 사용할 수 있다.

type NoInfer<T extends any> = [T][T extends any ? 0 : never];

그런데 튜플 타입의 인덱싱을 통해서 어떻게 추론을 막는건지 궁금해서 ChatGPT에게 물어보니 아래와 같은 답변을 해줬다.

타입 추론 방지

튜플과 인덱싱된 접근을 사용하는 것은 타입스크립트의 타입 추론 메커니즘을 '우회’하는 방법입니다. 특정 타입 T에 대해 [T][0]과 같은 표현을 사용하면, 타입스크립트는 T의 타입을 직접적으로 추론하는 대신, T 타입의 요소를 포함하는 튜플의 첫 번째 요소의 타입을 추론하게 됩니다. 이 과정에서 타입스크립트는 T의 타입을 '고정’시키고 추가적인 추론을 수행하지 않으려고 합니다.

인덱싱된 접근의 역할

인덱싱된 접근은 타입스크립트에게 타입 정보를 '사용’하도록 강제합니다. 즉, [T][0]은 타입스크립트에게 T 타입의 값을 단순히 사용하는 것이 아니라, 특정 구조(이 경우 튜플) 내에서 T의 타입을 확인하도록 요구합니다. 이 과정에서 타입 추론이 특정 지점에서 ‘멈추고’, 명시적으로 제공된 타입이나 이전에 추론된 타입을 그대로 사용하게 합니다.

이걸 보고 생각난 것이 never타입을 추론하기 위해서

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

같은 식으로 타입을 튜플의 원소로서 타입을 '확정(가두기)'한 뒤 참조하는 느낌과 비슷하다고 느꼈다.

또한 NoInfer유틸리티 타입에 대해 논의한 깃허브 이슈도 있으니 궁금하면 읽어봐도 재밌을 듯 하다.