Published on

TS 5.5 조건부 타입 infer가 never 되는 7가지

Authors

서로 다른 유틸리티 타입을 조합하다 보면 infer가 분명히 뭔가를 뽑아낼 것 같은데 결과가 never로 굳어버리는 순간이 있습니다. 특히 TS 5.5로 넘어오면서 타입 추론이 더 정교해진 만큼, 애매한 조건부 타입은 더 자주 never로 수렴하거나(또는 그 반대) 개발자가 기대한 방향과 달라질 수 있습니다.

이 글은 “TS 5.5에서 infernever가 되는” 상황을 7가지로 분류해, 왜 그런지어떻게 고칠지를 코드로 정리합니다.

전제: 아래 예제는 TypeScript 5.5 기준이며, strict 옵션을 켠 상태를 가정합니다.

준비: 재현용 헬퍼 타입

에러를 더 명확히 보기 위해 간단한 헬퍼를 하나 둡니다.

// 타입을 읽기 좋게 펼쳐주는 용도(IDE 표시 개선)
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;

// "이 타입이 never인지" 확인하는 유틸
export type IsNever<T> = [T] extends [never] ? true : false;

1) 분배 조건부 타입에서 유니온 한 조각이 실패해 never가 섞이는 경우

조건부 타입은 좌변이 “맨몸의 타입 파라미터”일 때 유니온에 대해 분배(distribute) 됩니다. 그 과정에서 어떤 유니온 멤버는 패턴 매칭에 실패해 never가 되고, 최종 결과가 never | ... 형태로 섞입니다. 이후 다른 연산(예: 인덱싱)에서 never가 전체를 망가뜨리는 경우가 많습니다.

type ExtractId<T> = T extends { id: infer I } ? I : never;

type A = ExtractId<{ id: string } | { name: string }>;
// 결과: string | never  -> 실질적으로 string

// 하지만 후속 연산에서 문제가 커질 수 있음
// 예: "id를 뽑았으니 string일 것"이라고 가정하고 string 메서드 체이닝 타입을 만들면…

type StringOps<T> = ExtractId<T> extends string ? "ok" : "bad";

type B = StringOps<{ id: string } | { name: string }>;
// 분배 결과가 기대와 다르게 퍼질 수 있음

해결: 분배를 막거나, 실패 케이스를 먼저 걸러내기

// 분배 방지: [T]로 감싸기
type ExtractIdNoDistribute<T> = [T] extends [{ id: infer I }] ? I : never;

type C = ExtractIdNoDistribute<{ id: string } | { name: string }>;
// 결과: never (유니온 전체가 {id: ...}를 만족하지 않으므로)

// 또는 "id가 있는 것만" 먼저 필터링
type OnlyWithId<T> = T extends { id: any } ? T : never;

type D = ExtractId<OnlyWithId<{ id: string } | { name: string }>>;
// 결과: string

분배는 강력하지만, “유니온 일부만 성공”하는 상황을 의도하지 않았다면 OnlyWithId 같은 필터를 두는 편이 안전합니다.

2) unknown이 섞여 조건이 애매해지고 infer가 무력화되는 경우

unknown은 “아무 타입이나 될 수 있음”이라서, 조건부 타입에서 패턴 매칭을 할 때 결과가 보수적으로 처리됩니다. 특히 unknown이 유니온에 섞이면 추론이 기대보다 쉽게 실패합니다.

type Elem<T> = T extends readonly (infer U)[] ? U : never;

type A = Elem<unknown[]>; // unknown

type B = Elem<unknown>;   // never (unknown은 배열 패턴을 만족한다고 확정할 수 없음)

type C = Elem<unknown | string[]>;
// 분배로 인해 Elem<unknown> | Elem<string[]> -> never | string -> string
// 여기까지는 괜찮아 보이지만, 이후 단계에서 unknown이 다시 합쳐지면 추론이 흔들림

해결: 입력 타입을 먼저 좁히는 가드 타입을 둔다

type IsArray<T> = T extends readonly any[] ? true : false;

type ElemSafe<T> = T extends readonly any[]
  ? T extends readonly (infer U)[]
    ? U
    : never
  : never;

type D = ElemSafe<unknown>;      // never
type E = ElemSafe<unknown[]>;    // unknown
type F = ElemSafe<string[] | unknown>; // string

핵심은 infer 자체가 문제가 아니라, 매칭 전제(배열임)가 성립하는지를 먼저 확인하는 것입니다.

3) any가 끼어들어 infernever처럼 보이는 경우(또는 반대로 전부 통과)

any는 조건부 타입에서 특별 취급을 받습니다. 때로는 “전부 통과”처럼 동작하고, 때로는 후속 연산에서 any가 퍼지면서 never와 비슷한 증상을 만들기도 합니다(특히 keyof/인덱싱과 조합될 때).

type Return<T> = T extends (...args: any[]) => infer R ? R : never;

type A = Return<any>; // any (조건부 타입이 any에 대해 특이 동작)

type B = Return<never>; // never

해결: any를 감지해 별도 분기

type IsAny<T> = 0 extends (1 & T) ? true : false;

type ReturnStrict<T> = IsAny<T> extends true
  ? never
  : T extends (...args: any[]) => infer R
    ? R
    : never;

type C = ReturnStrict<any>; // never (정책적으로 차단)

실무에서는 any를 “허용”할 건지 “차단”할 건지 정책을 정하고, 유틸리티 타입에 반영하는 편이 디버깅 비용을 크게 줄입니다.

4) 함수 오버로드 타입에서 infer가 기대와 다르게 never가 되는 경우

오버로드 함수 타입은 단일 시그니처가 아니라 여러 시그니처의 집합입니다. T extends (...args) => infer R 같은 패턴은 오버로드를 만났을 때 마지막 시그니처만 보거나, 교차/유니온 형태로 표현되며 추론이 꼬일 수 있습니다.

declare function f(x: string): number;
declare function f(x: number): string;

type F = typeof f;

type Ret<T> = T extends (...args: any[]) => infer R ? R : never;

type A = Ret<F>; // string | number 로 기대하지만 상황에 따라 한쪽만 보이거나 애매해질 수 있음

해결: 오버로드를 유니온으로 “펼친” 뒤 처리하거나, Parameters/ReturnType을 활용

// TS 내장 유틸을 우선 고려
type A1 = ReturnType<typeof f>; // string | number

// 혹은 오버로드를 유니온으로 바꾸는 패턴(상황에 따라 필요)
type OverloadToUnion<T> =
  T extends { (...a: infer A1): infer R1; (...a: infer A2): infer R2 }
    ? ((...a: A1) => R1) | ((...a: A2) => R2)
    : T extends (...a: infer A) => infer R
      ? (...a: A) => R
      : never;

type U = OverloadToUnion<typeof f>;
type A2 = Ret<U>; // string | number

오버로드가 3개 이상이면 OverloadToUnion을 더 확장해야 합니다. 복잡해질수록 내장 ReturnType/Parameters를 우선 고려하세요.

5) 튜플/배열 패턴에서 readonly, 가변 길이, 빈 튜플 때문에 실패하는 경우

infer로 튜플의 head/tail을 뽑을 때 가장 흔한 함정은 readonly와 빈 튜플, 그리고 string[] 같은 가변 배열을 동일한 패턴으로 처리하려는 시도입니다.

type Head<T> = T extends [infer H, ...any[]] ? H : never;

type A = Head<[1, 2, 3]>; // 1

type B = Head<[]>;        // never (빈 튜플)

type C = Head<readonly [1, 2, 3]>; // never (readonly 때문에 매칭 실패)

type D = Head<string[]>;  // string (혹은 never로 보이는 조합이 발생할 수 있음)

해결: readonly를 포함한 패턴으로 매칭하고, 빈 케이스를 분리

type HeadSafe<T extends readonly unknown[]> =
  T extends readonly [infer H, ...infer _Rest] ? H : never;

type A1 = HeadSafe<readonly [1, 2, 3]>; // 1

type B1 = HeadSafe<[]>; // never

빈 튜플에서 never가 나오는 건 “정상”입니다. 빈 배열을 허용해야 한다면 반환 타입을 undefined로 바꾸는 등 정책을 명확히 하세요.

6) 객체 인덱싱과 결합될 때 infernever로 고정되는 경우

infer로 키를 뽑아놓고, 그 키로 다시 인덱싱하는 패턴에서 never가 자주 등장합니다. 특히 “키가 존재한다”는 보장이 없으면 Knever로 떨어지고, 최종 인덱싱도 never가 됩니다.

type ValueById<T> =
  T extends { id: infer K }
    ? K extends keyof T
      ? T[K]
      : never
    : never;

type A = ValueById<{ id: "name"; name: string }>; // string

type B = ValueById<{ id: "missing"; name: string }>; // never

해결: 입력을 더 강하게 제약하거나, 실패를 never가 아닌 안전한 타입으로 바꾸기

// id는 반드시 keyof T여야 한다는 제약을 타입 레벨에서 강제
// T를 두 파라미터로 나누는 편이 표현력이 좋음
type ValueByKey<T, K extends keyof T> = T[K];

type A1 = ValueByKey<{ name: string }, "name">; // string

// 또는 실패 시 unknown/undefined로 정책 변경
type ValueByIdOrUndefined<T> =
  T extends { id: infer K }
    ? K extends keyof T
      ? T[K]
      : undefined
    : undefined;

never는 “불가능”을 의미하므로, 런타임에서 실제로 발생 가능한 케이스라면 undefined/unknown으로 돌려서 호출자에게 처리를 강제하는 편이 더 안전합니다.

7) infer가 교차 타입(&)과 만나면서 모순이 생겨 never가 되는 경우

교차 타입은 “둘 다 만족”해야 합니다. 두 타입이 논리적으로 모순이면 결과가 never가 될 수 있습니다. 이때 조건부 타입의 infer는 제대로 추론한 것처럼 보이다가도, 교차 과정에서 모순이 생기며 never로 붕괴합니다.

type WithIdString = { id: string };
type WithIdNumber = { id: number };

type X = WithIdString & WithIdNumber;
// id가 string이면서 number여야 하므로 사실상 불가능 -> id는 never에 가까운 상태

type ExtractId<T> = T extends { id: infer I } ? I : never;

type A = ExtractId<X>; // never

해결: 교차를 만들기 전에 호환성을 검증하거나, 설계를 유니온으로 바꾸기

// 교차 대신 유니온으로 모델링
type Y = WithIdString | WithIdNumber;

type B = ExtractId<Y>; // string | number

// 혹은 id 타입이 충돌하지 않도록 제네릭으로 통일
type WithId<TId> = { id: TId };

type Z = WithId<string> & { name: string }; // 정상

교차 타입은 “믹스인”처럼 쓰기 좋지만, 동일 필드의 타입이 충돌하면 never가 등장합니다. infernever가 됐다기보다, 원본 타입이 이미 불가능한 타입이 된 경우가 많습니다.

디버깅 체크리스트: infernever면 무엇부터 볼까

  1. 분배 조건부 타입인지 확인: T extends ...에서 T가 맨몸인지, [T]로 감싸야 하는지
  2. 입력에 unknown/any/never가 섞였는지 확인
  3. 오버로드 함수 타입을 대상으로 추론 중인지 확인
  4. 튜플 패턴에 readonly가 필요한지, 빈 튜플을 허용하는지 확인
  5. keyof/인덱싱과 결합된 경우 “키 존재” 제약이 충분한지 확인
  6. 교차 타입에서 필드 충돌이 없는지 확인

실무 팁: 타입 유틸은 "정책"을 박아 넣어야 한다

조건부 타입은 강력하지만, 프로젝트가 커질수록 “실패 시 never” 같은 기본 정책이 예상치 못한 곳에서 폭발합니다. 예를 들어 API 응답을 변환하는 타입 유틸에서 never가 섞이면, 호출자 쪽에서 타입이 갑자기 사라져 디버깅이 어려워집니다.

이럴 때는 다음처럼 명시적 정책을 선택해 유틸리티를 설계하는 게 좋습니다.

  • 실패 시 never(정말 불가능한 상태를 표현)
  • 실패 시 undefined(런타임에서 없을 수 있음을 표현)
  • 실패 시 unknown(호출자에게 좁히기를 강제)

TS 5.x에서 런타임 메타프로그래밍(예: 데코레이터)과 타입 레벨 설계가 함께 가는 경우가 많습니다. 데코레이터 기반 설계에서 타입 추론 함정도 함께 정리해두면 도움이 됩니다: TypeScript 5 데코레이터 완전 가이드 - 실무 패턴·함정

또한 모노레포에서 ESM 전환이나 번들러 설정 변화가 타입 정의(특히 .d.ts)에 영향을 주면서 any/unknown이 유입되는 경우도 잦습니다. 관련 이슈를 겪고 있다면 다음 글도 같이 참고하세요: Node.js ESM 전환 시 ERR_REQUIRE_ESM 해결 가이드

마무리

TS 5.5에서 infernever로 떨어지는 현상은 대부분 “추론이 실패”라기보다, 조건이 성립하지 않거나(패턴 불일치), 분배/오버로드/교차 같은 타입 연산 규칙에 의해 불가능이 전파되는 결과입니다.

위 7가지를 원인별로 분리해 보면, 해결책도 명확해집니다.

  • 분배를 의도했는지 먼저 결정하고
  • 입력 타입을 좁히는 가드/제약을 추가하고
  • 오버로드/readonly/교차 충돌 같은 구조적 함정을 피하면

infer 기반 유틸리티 타입을 훨씬 안정적으로 운영할 수 있습니다.