Published on

TS 5.5+ const type params로 타입 추론 폭발 막기

Authors

서버가 느려지면 타임아웃부터 의심하듯, 타입 시스템이 느려지면 먼저 “추론이 과해졌는지”를 의심해야 합니다. TypeScript 프로젝트에서 빌드나 에디터 타입체크가 갑자기 버벅이기 시작하는 순간이 있는데, 원인이 런타임이 아니라 타입 추론 단계에서 리터럴이 지나치게 보존되고, 그 결과로 조건부 타입, 매핑 타입, 유니온 분배가 과도하게 전개되는 경우가 많습니다.

TypeScript 5.5+에서 도입된 const type parameters(이하 const 타입 파라미터)는 이런 상황에서 “의도적으로 리터럴을 보존하도록” 제어할 수 있는 도구입니다. 아이러니하게 들릴 수 있지만, 핵심은 리터럴 보존을 함수 경계로 한정하고, 내부에서 필요한 만큼만 타입 연산을 수행하게 만들어 추론 폭발을 차단하는 데 있습니다.

아래에서는 const 타입 파라미터가 무엇인지, 어떤 상황에서 추론이 폭발하는지, 그리고 실무에서 성능과 DX를 동시에 챙기는 패턴을 정리합니다.

타입 추론 폭발(inference explosion)이란

대부분의 “타입이 느려지는” 문제는 다음 중 하나로 귀결됩니다.

  1. 값이 리터럴로 과도하게 추론됨
  2. 그 리터럴이 큰 유니온이나 긴 튜플로 퍼짐
  3. 조건부 타입의 분배(T extends ... ? ... : ...)가 유니온 크기만큼 반복됨
  4. 결과 타입이 또 다른 제네릭에 전달되며 연쇄적으로 커짐

예를 들어, 라우팅/이벤트/커맨드 테이블처럼 키가 많은 객체를 입력으로 받아 “키 유니온”을 만들고, 다시 그 키로 매핑 타입을 돌리는 패턴은 흔합니다.

type Handlers = Record<string, (...args: any[]) => any>

type EventName<T extends Handlers> = keyof T

type PayloadMap<T extends Handlers> = {
  [K in keyof T]: Parameters<T[K]>[0]
}

여기서 T가 거대한 객체 리터럴로 들어오고, keyof T가 수백 개 키 유니온으로 커지면 PayloadMap 같은 매핑 타입도 커집니다. 이 자체는 정상인데, 문제는 이런 타입이 여러 레이어의 제네릭을 거치며 반복 계산될 때입니다.

const 타입 파라미터가 하는 일

const 타입 파라미터는 “이 제네릭 파라미터에 들어오는 값의 타입을 가능한 한 리터럴로 유지해라”라는 의도를 타입 시스템에 전달합니다.

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

const a = id([1, 2, 3])
// a: readonly [1, 2, 3]

여기서 포인트는 단순히 리터럴 추론이 강화된다는 게 아니라, 리터럴 보존을 함수 경계에서 명시적으로 선택할 수 있게 됐다는 점입니다.

  • 이전에는 as const를 값 쪽에 붙이거나
  • 오버로드/헬퍼 타입으로 우회하거나
  • 호출부에서 타입 인수를 강제로 지정하는 식으로 해결했는데

이제는 API 설계자가 “이 함수는 입력을 리터럴로 캡처하는 게 맞다/아니다”를 제네릭 선언부에서 표현할 수 있습니다.

그런데 왜 이게 ‘추론 폭발을 막는’ 데 도움이 되나

핵심은 리터럴을 무작정 퍼뜨리는 게 아니라, 캡처 지점을 통제하는 데 있습니다.

실무에서는 보통 다음 둘 중 하나가 문제입니다.

  1. 호출부에서 리터럴이 너무 쉽게 string/number로 widen(확장)되어 타입 안전성이 떨어짐
  2. 반대로, 리터럴이 너무 넓은 영역으로 전파되어 타입 연산이 폭증함

const 타입 파라미터는 1번을 해결하는 도구로 알려져 있지만, 2번에도 유효합니다. 왜냐하면 리터럴을 캡처하는 경계를 한 군데로 모아두면, 그 이후 레이어에서는 의도적으로 widen하거나 단순화하는 전략을 적용할 수 있기 때문입니다.

즉,

  • 입력은 const로 “정확히” 받되
  • 내부 계산은 “필요한 만큼만” 하고
  • 외부로 내보낼 타입은 “적당히 단순화”하는 방식으로

타입 계산량을 제어할 수 있습니다.

패턴 1: 테이블 입력은 const로 캡처하고, 외부 타입은 단순화

예: 커맨드 테이블을 받아 dispatch를 만드는 유틸.

type AnyFn = (...args: any[]) => any

type Simplify<T> = { [K in keyof T]: T[K] } & {}

type CommandTable = Record<string, AnyFn>

type Dispatch<T extends CommandTable> = <K extends keyof T>(
  name: K,
  ...args: Parameters<T[K]>
) => ReturnType<T[K]>

export function createDispatcher<const T extends CommandTable>(table: T) {
  const dispatch: Dispatch<T> = (name, ...args) => {
    const fn = table[name]
    return fn(...(args as any))
  }

  // 외부로는 필요 이상으로 복잡한 타입이 새어 나가지 않게 정리
  return { dispatch } as Simplify<{ dispatch: Dispatch<T> }>
}

const d = createDispatcher({
  ping: (x: number) => x + 1,
  hello: (s: string) => s.toUpperCase(),
})

d.dispatch("ping", 1)
// d.dispatch("ping", "x")  // 타입 에러

여기서 const T는 테이블의 키를 정확히 캡처합니다. 하지만 반환 타입은 Simplify로 한 번 정리해 IDE에서 표시되는 타입이 과도하게 중첩되지 않도록 합니다.

이 “캡처는 정확히, 노출은 단순히”가 대규모 코드베이스에서 체감 성능을 크게 좌우합니다.

패턴 2: 내부 계산은 widen한 타입으로, 경계에서만 리터럴 유지

리터럴 키 유니온이 너무 커지면 그 자체로도 타입 연산 비용이 증가합니다. 이때는 내부에서만 widen해서 계산량을 줄일 수 있습니다.

type AnyRecord = Record<string, unknown>

function widenRecord<T extends AnyRecord>(x: T): Record<string, unknown> {
  return x
}

export function defineConfig<const T extends AnyRecord>(cfg: T) {
  // cfg는 리터럴로 캡처
  const raw = widenRecord(cfg) // 내부는 widen

  // 내부 로직은 raw 기반으로 처리(타입 연산 최소화)
  return {
    get(key: keyof T) {
      return cfg[key]
    },
    raw,
  }
}

요지는 “정확한 타입이 꼭 필요한 API 표면”과 “그럴 필요 없는 내부 구현”을 분리하는 것입니다. 런타임에서도 hot path에 불필요한 일을 안 하듯, 타입에서도 동일한 원칙이 통합니다.

패턴 3: as const 남발 대신 API에 의도를 넣기

기존에는 호출부에 as const를 붙여 리터럴을 보존하는 경우가 많았습니다.

const routes = {
  home: "/",
  user: "/users/:id",
} as const

하지만 이 방식은 호출부에 지식이 분산되고, 팀원이 놓치면 바로 widen되어 타입 안정성이 깨집니다.

대신 API를 이렇게 설계할 수 있습니다.

export function defineRoutes<const T extends Record<string, string>>(routes: T) {
  return routes
}

const routes = defineRoutes({
  home: "/",
  user: "/users/:id",
})

이제 리터럴 보존은 defineRoutes의 계약이 됩니다. 또한 추후에 반환 타입을 단순화하거나, 특정 키만 노출하는 식으로 타입 연산 범위를 쉽게 제어할 수 있습니다.

패턴 4: 큰 유니온 분배를 막는 “비분배 래핑”과 같이 쓰기

추론 폭발의 주요 원인 중 하나는 조건부 타입의 유니온 분배입니다. 이때는 전통적인 비분배 패턴(튜플로 감싸기)과 const 캡처를 함께 쓰면 효과가 큽니다.

type NonDistributive<T> = [T] extends [any] ? T : never

type IsString<T> = T extends string ? true : false

type IsStringNonDist<T> = [T] extends [string] ? true : false

type A = IsString<"a" | 1>        // true | false (분배)
type B = IsStringNonDist<"a" | 1> // false (비분배)
  • 입력 유니온이 커질수록 분배 비용이 증가합니다.
  • const 타입 파라미터로 리터럴을 정확히 잡되, 내부 조건부 타입에서는 비분배를 적용해 계산량을 줄일 수 있습니다.

언제 const 타입 파라미터를 쓰면 좋은가

다음 같은 API는 const 타입 파라미터의 효과가 큽니다.

  • 라우트/이벤트/커맨드/피처 플래그 등 “키 기반 테이블”을 받는 함수
  • tuple 입력을 받아 인덱스/길이 기반 타입을 만드는 함수
  • 문자열 리터럴을 받아 템플릿 리터럴 타입으로 파싱하는 함수

특히 “정확한 키 유니온이 필요하지만, 그 정확성이 프로젝트 전역으로 전파되면 부담”인 케이스에서, 캡처 지점을 한 곳으로 모을 수 있습니다.

주의점: const가 만능은 아니다

  1. 리터럴을 보존하면 타입이 더 커질 수 있습니다.
    • 그래서 “어디까지 정확해야 하는가”를 함께 설계해야 합니다.
  2. 반환 타입을 그대로 노출하면 IDE 표시 타입이 과도하게 복잡해질 수 있습니다.
    • Simplify 같은 정리 유틸을 고려하세요.
  3. 타입 연산 자체를 줄이는 설계가 더 중요합니다.
    • 큰 유니온을 여러 번 재계산하지 않게 중간 결과를 타입 별칭으로 캐싱하거나, 비분배 조건부 타입을 쓰는 식의 접근이 필요합니다.

실무 디버깅 체크리스트

타입 성능 이슈가 의심되면 아래를 순서대로 점검해보면 좋습니다.

  1. 특정 파일에서만 에디터가 느려지는가
  2. 큰 객체 리터럴(수십~수백 키)을 제네릭에 그대로 넣고 있는가
  3. keyof T 기반 유니온이 여러 조건부 타입을 연쇄적으로 통과하는가
  4. 템플릿 리터럴 타입 파싱이 과도하게 중첩되는가
  5. 반환 타입이 과하게 상세한 리터럴을 그대로 노출하고 있는가

이런 문제는 런타임 타임아웃을 네트워크 구간별로 쪼개 진단하듯, 타입도 “추론 경계”와 “연산 구간”을 분리해서 봐야 합니다. 비슷한 진단 관점은 인프라 트러블슈팅 글들에서도 통합니다. 예를 들어 네트워크 경로를 10분 안에 분해해보는 접근은 타입 병목을 찾을 때도 도움이 됩니다: EKS Pod→RDS 504 타임아웃 - SG·NACL·NAT 10분 진단

마무리: const는 “정확도”가 아니라 “경계 제어” 도구다

TypeScript 5.5+의 const 타입 파라미터는 단순히 리터럴 타입을 더 잘 잡아주는 문법이 아니라, 리터럴 추론을 어디서 캡처할지를 API 설계자가 결정하게 해줍니다. 이 경계를 잘 설계하면,

  • 호출부에서는 타입 안전성을 얻고
  • 내부에서는 타입 계산량을 줄이고
  • 외부로는 단순한 타입을 노출해

결과적으로 “타입 추론 폭발”을 체계적으로 막을 수 있습니다.

다음에 타입체크가 갑자기 느려졌다면, 무작정 any로 도망가기 전에 “리터럴이 어디서부터 어디까지 전파되는지”를 먼저 끊어보세요. const 타입 파라미터는 그 끊는 지점을 만들기 위한, 꽤 강력한 최신 도구입니다.