Published on

TS 5.5+ const 타입 파라미터로 추론 고치기

Authors

서버/프론트 공통으로 타입 안정성을 높이려다 보면, “분명 리터럴로 넘겼는데 타입이 string/number로 넓어져서” 제네릭 추론이 기대와 다르게 나오는 순간이 자주 생깁니다. 특히

  • 키 목록을 받아서 객체를 만드는 유틸
  • 이벤트 이름을 받아 핸들러 맵을 만드는 패턴
  • 라우트 세그먼트, 쿼리 키, i18n 키처럼 “문자열 리터럴 집합”이 핵심인 API

에서 이런 문제가 두드러집니다.

TypeScript 5.5+에는 이를 정면으로 해결하는 도구가 들어왔습니다. 바로 const 타입 파라미터(const type parameters) 입니다. 기존에는 as const를 호출부에서 강제하거나, 오버로드를 늘리거나, 조건부 타입으로 우회했는데, 이제는 함수/클래스의 타입 파라미터에 const를 붙여 “추론이 리터럴을 유지하도록” 만들 수 있습니다.

이 글은 “언제 추론이 깨지는지”, “TS 5.5+에서 어떻게 고치는지”, “as const/satisfies와의 역할 분담”까지 실무 관점으로 정리합니다.

관련해서 satisfies로 타입 좁힘이 기대대로 안 될 때의 패턴은 아래 글도 함께 보면 좋습니다.

왜 리터럴 추론이 깨질까

TypeScript는 “값”을 기반으로 타입을 추론하지만, 함수 인자로 전달되는 순간에는 일반적으로 더 넓은 타입으로 추론되는 경우가 많습니다.

대표적으로 아래 코드에서 keys는 호출부에서 ['id', 'name']을 넘겨도, 제네릭 Tstring[] 쪽으로 넓어져 버릴 수 있습니다.

function pick<T extends readonly string[]>(keys: T) {
  return keys
}

const a = pick(['id', 'name'])
// 기대: readonly ['id', 'name']
// 현실: string[] 또는 readonly string[]로 넓어질 수 있음

물론 호출부에서 as const를 쓰면 됩니다.

const b = pick(['id', 'name'] as const)

하지만 매번 호출부에서 as const를 강제하는 API는 사용성이 떨어지고, 팀 내 컨벤션으로도 잘 지켜지지 않습니다. 이때 TS 5.5+의 const 타입 파라미터가 빛을 발합니다.

TS 5.5+ const 타입 파라미터란

함수/클래스의 타입 파라미터 선언에서 const를 붙이면, 해당 타입 파라미터는 가능한 한 리터럴 타입으로 추론되도록 동작합니다.

핵심은 아래 한 줄입니다.

function pick<const T extends readonly string[]>(keys: T) {
  return keys
}

이제 호출부는 평범하게 써도 리터럴이 유지됩니다.

const a = pick(['id', 'name'])
// readonly ['id', 'name']

즉, “호출부 as const 강요”를 “라이브러리/유틸 내부의 타입 설계”로 흡수할 수 있습니다.

가장 흔한 실전 패턴 1: 키 목록으로 객체 만들기

예를 들어, 키 배열을 받아 { key: true } 같은 플래그 객체를 만드는 유틸을 만들 때가 많습니다.

문제: 키가 string으로 넓어져 결과 타입이 망가짐

function toFlags<T extends readonly string[]>(keys: T) {
  const out: Record<string, true> = {}
  for (const k of keys) out[k] = true
  return out
}

const flags = toFlags(['read', 'write'])
// flags 타입: Record<string, true>
// 기대: { read: true; write: true }

해결: const 타입 파라미터 + 매핑된 타입

type Flags<T extends readonly PropertyKey[]> = {
  [K in T[number]]: true
}

function toFlags<const T extends readonly PropertyKey[]>(keys: T): Flags<T> {
  const out = {} as Flags<T>
  for (const k of keys) out[k] = true
  return out
}

const flags = toFlags(['read', 'write'])
// { read: true; write: true }

포인트는 두 가지입니다.

  • T[number]로 “배열 원소들의 유니온”을 뽑는다
  • const T['read','write']가 튜플로 유지되게 한다

패턴 2: 이벤트/명령 디스패처에서 핸들러 맵 추론

이벤트 이름을 배열로 받고, 해당 이벤트만 허용되는 emit을 만들고 싶다고 합시다.

기존 방식: 호출부 as const 의존

function createEmitter<T extends readonly string[]>(events: T) {
  type E = T[number]
  return {
    emit(event: E) {
      // ...
    },
  }
}

const emitter = createEmitter(['open', 'close'])
// emit 파라미터가 string으로 넓어질 수 있음

개선: const 타입 파라미터로 API 사용성 복구

function createEmitter<const T extends readonly string[]>(events: T) {
  type E = T[number]
  return {
    emit(event: E) {
      return event
    },
  }
}

const emitter = createEmitter(['open', 'close'])

emitter.emit('open')
// emitter.emit('other')  // 컴파일 에러

이 패턴은 라우팅, 권한 스코프, 피처 플래그 키 목록 등에도 그대로 적용됩니다.

패턴 3: “정확한 튜플”이 필요한 API (REST 라우트, 쿼리 키)

React Query 같은 라이브러리에서 쿼리 키를 튜플로 쓰는 이유는 “키가 정확히 고정되어야 캐시가 안정적”이기 때문입니다.

예를 들어, ['user', userId] 형태의 키를 받아 어떤 동작을 만들 때, string[]로 넓어지면 타입 안전성이 크게 떨어집니다.

function makeKey<const T extends readonly unknown[]>(...parts: T) {
  return parts
}

const key = makeKey('user', 123)
// readonly ['user', 123]

이제 key[0]'user'로 유지되고, key[1]123으로 유지됩니다. as const 없이도요.

라우트 세그먼트 빌더 예시

function route<const T extends readonly string[]>(...segments: T) {
  return '/' + segments.join('/')
}

const r = route('users', '123', 'settings')
// 타입은 string이지만, 세그먼트 자체는 T로 보존 가능

문자열 결과 자체는 string이지만, 내부적으로 T를 함께 반환하도록 설계하면 “세그먼트 기반 타입 계산”도 가능합니다.

as const vs const 타입 파라미터: 무엇을 언제 쓰나

  • as const
    • 호출부에서 “이 값은 리터럴로 고정”을 강제
    • 라이브러리 외부 입력(예: JSON 상수 테이블)처럼 값 자체를 고정해야 할 때 유용
  • const 타입 파라미터
    • 라이브러리/유틸 설계자가 “추론 정책”을 바꿔 API 사용성을 높임
    • 호출부의 실수를 줄이고, 팀 전체 코드 품질을 안정화

실무 기준으로는:

  • 공용 유틸 함수: const 타입 파라미터를 먼저 고려
  • 상수 테이블/설정 객체: as const 또는 satisfies를 고려

as const는 “값을 얼린다”는 느낌이고, const 타입 파라미터는 “추론을 리터럴 친화적으로 만든다”는 느낌입니다.

satisfies와 함께 쓰면 더 좋은 경우

const 타입 파라미터는 “인자 추론”을 고치고, satisfies는 “값이 특정 스키마를 만족하는지 검증하면서도 리터럴을 유지”하는 데 강합니다.

예를 들어 라벨 맵을 만들 때:

type Labels = Record<string, { ko: string; en: string }>

const labels = {
  login: { ko: '로그인', en: 'Login' },
  logout: { ko: '로그아웃', en: 'Logout' },
} satisfies Labels

// labels.login.ko 는 '로그인' 리터럴로 유지될 수 있음

그리고 이 키 목록을 다른 유틸로 넘길 때 const 타입 파라미터가 받쳐주면, 키 추론이 끝까지 유지됩니다.

function keysOf<const T extends Record<PropertyKey, unknown>>(obj: T) {
  return Object.keys(obj) as Array<keyof T>
}

const k = keysOf(labels)
// ("login" | "logout")[]

Object.keys의 런타임 한계를 타입으로 보정하는 패턴은 자주 쓰이므로, 이런 조합은 생산성이 큽니다.

마이그레이션 팁: 기존 제네릭 유틸에 최소 변경으로 적용하기

대부분의 경우, 변경은 “타입 파라미터에 const 추가”로 끝납니다.

변경 전

export function defineColumns<T extends readonly string[]>(cols: T) {
  return cols
}

변경 후

export function defineColumns<const T extends readonly string[]>(cols: T) {
  return cols
}

이 한 줄로 호출부의 as const 제거가 가능해지고, 결과 타입이 더 정확해집니다.

주의할 점도 있습니다.

  • const 타입 파라미터는 TS 5.5+에서 사용 가능하므로, 라이브러리라면 typescript 버전 정책을 명확히 해야 합니다.
  • 너무 공격적으로 리터럴을 고정하면, 의도적으로 넓은 타입을 원하던 호출부가 불편해질 수 있습니다. 이 경우 “넓은 타입을 원할 때는 명시적으로 타입 인자를 주도록” 가이드를 제공하면 됩니다.

예:

function pick<const T extends readonly string[]>(keys: T) {
  return keys
}

// 넓은 타입이 필요하면 명시적으로
const wide = pick<string[]>(['a', 'b'])

언제 특히 효과가 큰가

다음 상황이라면 const 타입 파라미터 도입 효과가 큽니다.

  1. 배열 인자 기반으로 타입을 계산하는 유틸이 많다
  2. 호출부에서 as const가 반복되고 있다
  3. 라우트/이벤트/권한 스코프처럼 “문자열 리터럴 집합”이 핵심 도메인이다
  4. T[number]를 활용한 매핑 타입을 자주 쓴다

이런 곳에 const 타입 파라미터를 적용하면, 코드가 짧아지는 것보다도 “타입이 의도대로 흘러가서 디버깅 시간이 줄어드는” 효과가 큽니다.

마무리

TS 5.5+의 const 타입 파라미터는 단순 문법 설탕이 아니라, 라이브러리/유틸 설계에서 추론 품질을 통제할 수 있게 해주는 장치입니다. 호출부의 as const 의존을 줄이고, 키/세그먼트/이벤트 같은 리터럴 중심 도메인에서 타입 안정성을 크게 끌어올릴 수 있습니다.

기존에 satisfies로도 해결이 안 되던 “함수 인자에서 리터럴이 사라지는 문제”를 겪고 있었다면, 가장 먼저 function f<const T ...> 형태로 한 군데만 바꿔서 체감해보는 것을 권합니다.