Published on

TS 5.5+ const 타입 파라미터로 infer 깨짐 해결

Authors

서드파티 라이브러리나 사내 유틸에서 infer를 적극적으로 쓰다 보면, TypeScript 5.5+로 올라간 뒤 “예전엔 되던 타입 추론이 갑자기 never가 된다”거나 “리터럴이 과하게 고정되어 오히려 추론이 실패한다” 같은 이슈를 종종 만납니다. 그 중심에 const 타입 파라미터가 있습니다.

이 글에서는

  • TS 5.5+의 const 타입 파라미터가 무엇을 바꿨는지
  • 어떤 형태로 infer가 깨지는지(재현 코드)
  • 깨짐을 의도적으로 제어하는 실전 해결책

을 정리합니다.

문제 유형 자체는 “추론 규칙이 바뀌어서 생기는 회귀”에 가깝습니다. 운영 환경에서 이런 류의 회귀를 디버깅하는 접근은 분산 시스템의 타임아웃 원인 분석과도 닮아 있습니다. 추론이 실패하는 지점을 단계적으로 좁혀가는 방식은 gRPC MSA에서 DEADLINE_EXCEEDED 원인 9가지에서 소개한 체크리스트 사고법과 유사하게 적용할 수 있습니다.

const 타입 파라미터가 바꾼 것

TypeScript는 원래도 as const를 통해 리터럴을 강하게 고정할 수 있었습니다. TS 5.0부터는 함수 제네릭에 const를 붙여 **타입 파라미터 자체가 리터럴로 “최대한 유지”**되도록 만들 수 있습니다.

// TS 5.0+
function id<const T>(value: T): T {
  return value;
}

const a = id(["x", "y"]);
// a: readonly ["x", "y"] 처럼 더 강한 리터럴/튜플로 잡힐 수 있음

이 변화는 장점이 큽니다.

  • 라우트 정의, 이벤트 이름, 액션 타입 등에서 문자열 리터럴이 잘 보존됨
  • 튜플 기반 API에서 as const를 매번 쓰지 않아도 됨

하지만 기존 유틸이 **“적당히 widen 되길 기대”**하거나, infer일반 배열/일반 객체 형태로 들어올 걸 전제로 만들었으면, const 제네릭으로 인해 입력이 너무 구체화되어 오히려 조건부 타입이 원하는 분기로 안 타는 일이 생깁니다.

깨짐 패턴 1: 튜플이 너무 강하게 고정되어 분기 실패

다음은 흔한 패턴입니다. “배열이면 요소 타입을 뽑고, 아니면 그대로” 같은 유틸.

type Elem<T> = T extends Array<infer U> ? U : T;

이 유틸은 string[]에는 잘 동작하지만, readonly 튜플에는 기대와 다르게 동작할 수 있습니다.

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

type B = Elem<readonly ["a", "b"]>;
// 기대: "a" | "b"
// 실제: readonly ["a", "b"] (분기 실패)

왜냐하면 readonly ["a", "b"]Array<...>에 할당되지 않고, ReadonlyArray<...> 쪽에 가깝기 때문입니다. const 타입 파라미터가 튜플을 readonly로 더 자주 고정시키면서, 이런 깨짐이 더 자주 노출됩니다.

해결 1: Array 대신 ReadonlyArray로 받기

type Elem<T> = T extends ReadonlyArray<infer U> ? U : T;

type B2 = Elem<readonly ["a", "b"]>; // "a" | "b"

실무 팁은 “배열을 다루는 유틸은 기본적으로 ReadonlyArray를 기준으로 짠다”입니다. 입력이 mutable이든 readonly든 모두 커버합니다.

깨짐 패턴 2: infer가 의도치 않게 never로 수렴

const 제네릭으로 인해 타입이 과하게 구체화되면, 조건부 타입에서 “분배”가 일어나면서 예상치 못한 never가 만들어지기도 합니다.

예를 들어, 이벤트 맵에서 핸들러 시그니처를 만들 때 이런 코드를 많이 씁니다.

type HandlerOf<T> = T extends { type: infer K; payload: infer P }
  ? (type: K, payload: P) => void
  : never;

여기에 const 제네릭을 붙인 API가 있다고 해봅시다.

function on<const E extends { type: string; payload: unknown }>(event: E) {
  // ...
  return event;
}

const ev = on({ type: "LOGIN", payload: { id: 1 } });
// ev.type이 "LOGIN"으로 강하게 고정됨

문제는 HandlerOf 같은 유틸이 “유니온 이벤트”를 기대하는데, const 제네릭이 개별 리터럴을 너무 강하게 잡아버리면, 다른 레이어에서 이벤트 유니온으로 합칠 때 분배가 꼬이거나, type 키가 string으로 widen 되지 않아 특정 조건을 통과하지 못하는 일이 생깁니다.

이 패턴은 코드베이스마다 형태가 다양한데, 핵심은

  • 입력 타입이 너무 구체적이라 조건부 타입이 예상한 형태로 매칭되지 않음
  • 분배 조건부 타입이 유니온을 쪼개며 never를 만들어 전체가 망가짐

입니다.

해결 2: 분배를 막는 래핑(Non-distributive)

조건부 타입에서 분배를 막고 싶다면 튜플로 감싸는 패턴이 정석입니다.

type NonDistHandlerOf<T> = [T] extends [{ type: infer K; payload: infer P }]
  ? (type: K, payload: P) => void
  : never;

이렇게 하면 T가 유니온이더라도 한 번에 평가되어, 의도치 않은 never 전염을 줄일 수 있습니다.

깨짐 패턴 3: “추론용 시그니처”가 const 제네릭 때문에 역효과

실전에서 가장 많이 보는 케이스는 “타입 추론을 유도하기 위해 제네릭을 여러 개 두고 infer로 꺼내는” 빌더/팩토리 API입니다.

예를 들어, 라우트 정의를 만들고 파라미터를 추론하는 함수.

type ParamsOf<Path extends string> =
  Path extends `${string}:${infer P}/${infer Rest}`
    ? P | ParamsOf<`/${Rest}`>
    : Path extends `${string}:${infer P}`
      ? P
      : never;

function route<const P extends string>(path: P) {
  return { path } as const;
}

const r = route("/users/:id");
// P가 리터럴로 고정되며 ParamsOf가 잘 동작하는 것처럼 보임

겉보기엔 좋아 보이지만, 기존 코드가 “path는 string으로 widen되고, 나중에 다른 레이어에서 조합해 유니온을 만들고 infer로 뽑는다” 같은 설계였다면, const P가 중간 단계에서 타입을 너무 고정해 조합 로직이 깨질 수 있습니다.

해결 3: API를 두 층으로 분리(추론 전용과 실행 전용)

핵심은 “리터럴 고정이 필요한 지점”과 “widen이 필요한 지점”을 분리하는 것입니다.

// 1) 리터럴을 보존하는 정의 단계
function defineRoute<const P extends string>(path: P) {
  return { path } as const;
}

// 2) 실행 단계에서는 widen해서 받기
function registerRoute(route: { path: string }) {
  // 런타임 등록 로직
}

const r1 = defineRoute("/users/:id");
registerRoute(r1); // OK

이 패턴은 Next.js나 라우팅/스키마 계열 DSL에서 특히 효과적입니다.

const 타입 파라미터가 있는 코드에서 infer를 안정화하는 5가지 체크리스트

1) 배열 계열은 ReadonlyArray를 기준으로

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

readonly 튜플을 기본으로 생각하면 TS 5.5+에서 훨씬 덜 깨집니다.

2) 조건부 타입 분배가 필요 없으면 [T] extends ...로 감싸기

type IsEvent<T> = [T] extends [{ type: string }] ? true : false;

유니온 입력에서 의도치 않은 분배를 막는 가장 싼 보험입니다.

3) “추론을 위한 제네릭”과 “제약을 위한 제네릭”을 분리

다음처럼 한 함수에서 모든 걸 해결하려 하면 const 제네릭과 충돌이 잦습니다.

// 위험: 추론과 제약이 한 군데 섞임
function make<const T extends { kind: string }>(value: T) {
  return value;
}

대신 오버로드나 두 단계 API로 분리합니다.

function make<T>(value: T): T;
function make<const T>(value: T): T {
  return value;
}

오버로드는 “외부에 보여줄 타입”을 안정화하는 데 도움이 됩니다.

4) satisfies로 리터럴 보존과 구조 검증을 동시에

리터럴을 유지하면서도 특정 형태를 만족하는지만 확인하고 싶다면 satisfies가 const 제네릭보다 충돌이 적습니다.

type Event = { type: string; payload: unknown };

const ev = {
  type: "LOGIN",
  payload: { id: 1 },
} satisfies Event;

// ev.type은 "LOGIN"으로 유지되면서, Event 구조도 검증됨

5) infer가 깨졌을 때는 “매칭 대상 타입”부터 의심

대부분의 회귀는 infer 자체가 아니라, extends 오른쪽에 둔 패턴이 너무 좁아서 매칭이 실패하는 것입니다.

  • Array vs ReadonlyArray
  • { a: string } vs { readonly a: string }
  • string vs 문자열 리터럴 유니온

이런 차이가 TS 5.5+에서 더 자주 표면화됩니다.

재현 가능한 미니 예제: const 제네릭 도입 후 요소 추론이 never가 되는 케이스

아래는 “옵션 배열을 받아 key 유니온을 만들기” 같은 흔한 패턴입니다.

type Option = { key: string; label: string };

type KeysOf<T> = T extends ReadonlyArray<infer U>
  ? U extends { key: infer K }
    ? K
    : never
  : never;

function defineOptions<const T extends readonly Option[]>(opts: T) {
  return opts;
}

const opts = defineOptions([
  { key: "a", label: "A" },
  { key: "b", label: "B" },
] as const);

type K = KeysOf<typeof opts>; // "a" | "b"

여기서 만약 KeysOfArray<infer U>로 작성했다면, optsreadonly 튜플로 고정되는 순간 매칭이 실패해 never가 될 가능성이 큽니다. TS 5.5+에서 const 제네릭을 도입하면서 “리터럴이 더 잘 보존되는” 코드가 많아졌고, 그 결과 이런 never가 더 자주 등장합니다.

마이그레이션 전략: TS 5.5+에서 안전하게 고치는 순서

  1. 깨지는 타입을 최소 재현으로 줄입니다. 입력 타입을 type Debug = ...로 별칭화해 “어디서 readonly가 붙었는지”를 먼저 확인합니다.
  2. 배열 유틸은 ReadonlyArray 기반으로 통일합니다.
  3. 조건부 타입이 유니온에서 분배되며 깨지는지 확인하고, 필요하면 [T] extends ...로 분배를 차단합니다.
  4. const 제네릭을 꼭 써야 하는 API와, 오히려 widen이 필요한 API를 분리합니다.
  5. 리터럴 보존이 목적이면 const 제네릭 대신 satisfies가 더 안전한지 검토합니다.

이런 식의 “원인 분해 후 단계적 수정”은 CI에서만 터지는 인증/배포 오류를 좁혀갈 때와 접근이 같습니다. 예를 들어 GitHub Actions OIDC AWS 배포 InvalidIdentityToken 해결처럼, 한 번에 전체를 바꾸기보다 실패 지점을 쪼개는 것이 핵심입니다.

결론

TS 5.5+에서 const 타입 파라미터는 리터럴 추론을 크게 개선하지만, 그만큼 기존 infer 기반 유틸이 “widen을 전제로 한 매칭”을 하고 있었다면 깨질 수 있습니다. 해결의 요지는 단순합니다.

  • 배열은 ReadonlyArray로 받기
  • 조건부 타입 분배를 의식적으로 제어하기
  • const 제네릭을 쓰는 레이어와 widen 레이어를 분리하기
  • 필요하면 satisfies로 목적을 대체하기

위 패턴을 적용하면, const 타입 파라미터의 장점은 유지하면서도 infer 회귀를 안정적으로 제거할 수 있습니다.