Published on

TS 5.4 const 타입 매개변수 실무 패턴

Authors

서드파티 SDK, 라우터, 이벤트 버스, 설정 로더처럼 문자열/배열/객체 리터럴을 많이 다루는 코드에서는 “리터럴 타입이 의도치 않게 string/number로 넓혀지는 문제”가 빈번합니다. TypeScript 5.4의 const 타입 매개변수(const type parameters)는 이 문제를 함수 API 설계 차원에서 해결할 수 있는 도구입니다.

핵심은 간단합니다.

  • 호출자가 as const를 매번 붙이지 않아도 리터럴을 리터럴로 유지
  • 제네릭을 통해 들어온 값이 “최대한 좁은 타입”으로 추론되도록 강제
  • 결과적으로 키 오타, 잘못된 액션/이벤트 이름, 설정 누락 같은 실수를 컴파일 타임에 잡음

이 글에서는 TS 5.4 const 타입 매개변수를 실무에서 가장 많이 쓰는 패턴으로 정리하고, 언제 satisfies나 오버로드가 더 나은지도 함께 다룹니다.

관련해서 satisfies를 함께 쓰는 패턴은 아래 글도 참고하면 좋습니다.

const 타입 매개변수란

TypeScript 5.4부터 제네릭 매개변수에 const 한정자를 붙일 수 있습니다.

// TS 5.4+
function f<const T>(value: T) {
  return value
}

이때 T는 가능한 한 “리터럴에 가깝게” 추론됩니다. 즉, 다음과 같은 상황에서 효과가 큽니다.

  • 배열 리터럴: ['a', 'b']string[]로 넓어지는 문제
  • 객체 리터럴: { mode: 'prod' }{ mode: string }으로 넓어지는 문제
  • 튜플/readonly 보존: 인덱스 기반 타입 계산에서 정확도가 올라감

주의할 점도 있습니다.

  • const 타입 매개변수는 “값을 상수로 만든다”가 아니라 “타입 추론을 좁히는 힌트”입니다.
  • 런타임 불변성을 보장하지 않습니다. 불변이 필요하면 Object.freeze 같은 별도 처리가 필요합니다.

패턴 1: 문자열 유니온을 만드는 keys() 유틸

설정 키, 쿼리 파라미터, 기능 플래그 같은 곳에서 키 목록을 한 번 정의하고, 그 목록 기반으로 타입을 만들고 싶을 때가 많습니다.

기존에는 호출부에 as const를 붙이거나, readonly 튜플로 강제해야 했습니다.

// 기존 방식: 호출부가 as const를 알아야 함
const K = ['host', 'port'] as const
type Key = (typeof K)[number] // 'host' | 'port'

TS 5.4에서는 유틸 자체가 리터럴 보존을 해줄 수 있습니다.

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

const K = keys('host', 'port')
// typeof K: readonly ['host', 'port']

type Key = (typeof K)[number] // 'host' | 'port'

실무에서 이 패턴은 다음에 특히 유용합니다.

  • .env 키를 코드에서 안전하게 다루기
  • feature flag 키 오타 방지
  • API query param allowlist

패턴 2: 라우팅 테이블을 “오타 불가능”하게 만들기

Next.js, Express, Fastify 등에서 라우팅 이름과 경로 파라미터를 강하게 묶고 싶을 때가 있습니다.

예를 들어 “라우트 이름으로 경로를 생성하는” 헬퍼를 만든다고 해봅시다.

type RouteDef = {
  name: string
  path: string
}

export function defineRoutes<const T extends readonly RouteDef[]>(routes: T) {
  return routes
}

const routes = defineRoutes([
  { name: 'home', path: '/' },
  { name: 'user', path: '/users/:id' },
] as const)

type RouteName = (typeof routes)[number]['name'] // 'home' | 'user'

여기서 as const를 제거하고 싶다면, 배열 전체를 인자로 받는 형태보다 “스프레드 인자”가 더 잘 먹힙니다.

export function defineRoutes2<const T extends readonly RouteDef[]>(...routes: T) {
  return routes
}

const routes2 = defineRoutes2(
  { name: 'home', path: '/' },
  { name: 'user', path: '/users/:id' },
)

type RouteName2 = (typeof routes2)[number]['name'] // 'home' | 'user'

이제 buildPath('user', { id: '42' }) 같은 함수를 만들 때, name 오타는 컴파일 타임에 차단됩니다.

이런 “DX를 올리는 타입 설계”는 프론트 성능/UX 문제를 잡는 것만큼이나 생산성에 큰 영향을 줍니다. UI 상호작용 지연을 다루는 글로는 React useTransition 무한 로딩·깜빡임 해결법도 함께 참고할 만합니다.

패턴 3: 이벤트 버스 on/emit을 안전하게 묶기

이벤트 이름을 문자열로 쓰는 시스템은 오타에 매우 취약합니다. const 타입 매개변수로 이벤트 맵을 선언하는 API를 만들면, 문자열 이벤트를 “타입 안전한 프로토콜”로 바꿀 수 있습니다.

type EventMap = Record<string, unknown>

export function createBus<const E extends EventMap>(events: E) {
  type Name = keyof E

  return {
    on<N extends Name>(name: N, handler: (payload: E[N]) => void) {
      // ...
    },
    emit<N extends Name>(name: N, payload: E[N]) {
      // ...
    },
  }
}

const bus = createBus({
  'user:login': { userId: '' as string },
  'user:logout': null as null,
})

bus.on('user:login', (p) => {
  p.userId.toUpperCase()
})

// bus.emit('user:logni', { userId: '1' }) // 오타: 컴파일 에러
// bus.emit('user:logout', { userId: '1' }) // payload 타입 불일치: 컴파일 에러
bus.emit('user:logout', null)

포인트는 events 인자에서 keyof E가 리터럴로 유지되어야 한다는 점입니다. const E가 없으면 keyof E가 넓어져 이벤트 이름 안정성이 떨어질 수 있습니다.

패턴 4: 설정 스키마를 기반으로 get() 타입을 추론하기

환경변수/원격 설정/JSON 설정을 읽는 config.get()은 런타임 오류가 자주 나는 지점입니다. const 타입 매개변수로 스키마를 선언하면, 키와 반환 타입을 강하게 연결할 수 있습니다.

type Schema = Record<string, 'string' | 'number' | 'boolean'>

type InferValue<T> =
  T extends 'string' ? string :
  T extends 'number' ? number :
  T extends 'boolean' ? boolean :
  never

export function createConfig<const S extends Schema>(schema: S) {
  return {
    get<K extends keyof S>(key: K): InferValue<S[K]> {
      // 실제 구현에서는 schema[key]를 보고 파싱
      throw new Error('not implemented')
    },
  }
}

const config = createConfig({
  NODE_ENV: 'string',
  PORT: 'number',
  FEATURE_X: 'boolean',
})

const port = config.get('PORT')
// port: number

이 패턴이 실무에서 좋은 이유는 다음과 같습니다.

  • 키 오타가 즉시 에러
  • 반환 타입이 자동으로 맞춰져서 캐스팅이 사라짐
  • 설정 변경이 “타입 변경”으로 전파되어 리팩터링이 쉬움

패턴 5: i18n 번역 키를 자동 생성하기

i18n에서 t('home.title') 같은 키는 문자열이라서 깨지기 쉽습니다. 번역 리소스를 객체로 들고 있다면, const 타입 매개변수로 리소스를 받아 키 경로 유니온을 만들 수 있습니다.

간단한 1-depth 예시부터 보겠습니다.

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

const messages = defineMessages({
  'home.title': 'Home',
  'home.subtitle': 'Welcome',
})

type MessageKey = keyof typeof messages

function t<K extends MessageKey>(key: K): (typeof messages)[K] {
  return messages[key]
}

t('home.title')
// t('home.tilte') // 오타: 컴파일 에러

실제로는 중첩 객체 경로 타입, 네임스페이스 분리 등 더 복잡해지지만, 출발점은 “키 리터럴이 유지되는 입력 타입”입니다.

패턴 6: pick()/omit() 같은 객체 유틸의 정확도 올리기

객체에서 일부 키만 뽑는 유틸은 흔하지만, 키 배열이 string[]로 넓어지는 순간 타입 정확도가 무너집니다.

export function pick<const O extends object, const K extends readonly (keyof O)[]>(
  obj: O,
  keys: K,
): Pick<O, K[number]> {
  const out: Partial<O> = {}
  for (const k of keys) {
    ;(out as any)[k] = (obj as any)[k]
  }
  return out as Pick<O, K[number]>
}

const user = { id: 1, name: 'a', admin: false }

const a = pick(user, ['id', 'name'])
// a: { id: number; name: string }

// const b = pick(user, ['id', 'nope']) // 'nope'는 keyof user가 아님

여기서 const K가 핵심입니다. 키 배열이 튜플로 유지되면 K[number]가 정확한 유니온이 됩니다.

언제 const 타입 매개변수가 효과가 없나

1) 이미 변수로 한 번 받으면서 넓어진 경우

다음처럼 중간 변수에서 타입이 넓어지면, 함수에서 const 제네릭을 써도 되돌리기 어렵습니다.

const arr = ['a', 'b'] // string[] 로 넓어질 수 있음

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

const r = f(arr) // r도 string[] 쪽으로 기울 수 있음

해결책은 다음 중 하나입니다.

  • 선언 시점에 as const를 사용
  • satisfies로 의도를 고정
  • 애초에 API를 ...rest 형태로 설계해서 리터럴이 바로 들어오게 만들기

2) 런타임에 생성된 값

런타임 조합으로 만들어진 배열/객체는 리터럴 정보를 잃는 게 정상입니다. 이때는 const 타입 매개변수로 “있지도 않은 리터럴”을 만들 수 없습니다. 대신 스키마/검증(예: zod) 기반으로 접근하는 편이 안전합니다.

satisfies와 함께 쓰는 실무 조합

const 타입 매개변수는 “함수 경계에서의 추론”에 강하고, satisfies는 “선언 지점에서의 형태 검증”에 강합니다. 둘을 같이 쓰면 다음이 좋아집니다.

  • 객체 리터럴의 키는 리터럴로 유지
  • 값의 형태는 특정 인터페이스를 만족
  • 과도한 타입 단언을 줄임
type RoutesShape = Record<string, { path: string; auth?: 'public' | 'private' }>

const routeMap = {
  home: { path: '/', auth: 'public' },
  settings: { path: '/settings', auth: 'private' },
} satisfies RoutesShape

export function createRouter<const R extends RoutesShape>(routes: R) {
  return {
    pathOf<K extends keyof R>(name: K) {
      return routes[name].path
    },
  }
}

const router = createRouter(routeMap)
router.pathOf('home')
// router.pathOf('hom') // 컴파일 에러

routeMapsatisfies로 모양을 검증하고, createRouterconst R로 키 리터럴을 최대한 유지합니다.

팀 적용 체크리스트

실무에 도입할 때는 아래를 기준으로 적용 범위를 잡으면 시행착오가 줄어듭니다.

  • “문자열 키/이벤트/액션”이 많은 모듈부터 적용 (라우팅, 이벤트, 커맨드)
  • 호출부에 as const가 반복되는 유틸부터 개선
  • ...rest 시그니처로 바꿔도 API가 자연스러운지 검토
  • 런타임 불변성이 필요하면 타입이 아니라 런타임에서 freeze/복사 정책을 별도로 설계

마무리

TypeScript 5.4의 const 타입 매개변수는 “리터럴을 리터럴로 유지하게 만드는 함수 API 설계 도구”입니다. 특히 라우팅/이벤트/i18n/설정/유틸 함수처럼 문자열과 리터럴 컬렉션이 핵심인 영역에서, 호출부의 단언을 줄이고 타입 안정성을 크게 끌어올립니다.

정리하면 다음 두 문장으로 귀결됩니다.

  • 호출부가 as const를 기억하게 하지 말고, 라이브러리/공용 유틸이 리터럴 추론을 책임지게 하자.
  • const 타입 매개변수는 satisfies와 조합할 때 가장 깔끔한 “검증 + 추론” 구조가 된다.