Published on

TS 5.5 const type params로 리터럴 추론 고치기

Authors

서버/프론트 경계에서 타입 안정성을 밀어붙이다 보면, 결국 발목을 잡는 건 “리터럴 추론이 왜 갑자기 string이 됐지?” 같은 순간입니다. 특히 fetch 래퍼, 라우팅 헬퍼, 이벤트 이름/액션 타입, 설정 키 목록처럼 문자열 리터럴 유니온을 기반으로 API를 설계할 때 이 문제가 자주 터집니다.

TypeScript는 기본적으로 “최대한 유연하게 쓰게 해주자”는 방향으로 추론이 동작합니다. 그래서 함수 인자로 들어온 값이 리터럴이어도, 제네릭을 거치거나 배열/객체로 포장되는 순간 리터럴이 넓은 타입으로 확장(widening) 되는 경우가 많습니다.

TypeScript 5.5에서 도입된 const type parameters(이하 const 타입 파라미터)는 이 문제를 정면으로 해결합니다. 이 글에서는

  • 리터럴 추론이 깨지는 대표 패턴
  • 기존 우회책의 한계
  • TS 5.5 const 타입 파라미터로 고치는 방법
  • 실무에서 바로 쓰는 헬퍼 패턴

을 코드로 정리합니다.

리터럴 추론이 깨지는 전형적인 상황

1) 제네릭 함수에서 배열 인자가 string[] 으로 넓어짐

예를 들어 “허용된 키 목록”을 받아서 그 키만 pick 하는 헬퍼를 만든다고 합시다.

function pickKeys<T, K extends keyof T>(obj: T, keys: K[]) {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const user = { id: 1, name: "a", role: "admin" as const };

const picked = pickKeys(user, ["id", "name"]);
// 기대: Pick<{...}, "id" | "name">
// 현실(자주): keys가 string[]로 넓어지면 K가 제대로 추론되지 않거나,
//             호출 지점에서 추가 타입 힌트가 필요해짐

왜 이런 일이 생길까요?

  • ["id", "name"] 는 문맥에 따라 ("id" | "name")[] 로도 추론될 수 있지만
  • 제네릭 경계에서 K[] 를 만족시키기 위해 넓게 잡히는 경우가 있습니다.
  • 특히 keys 를 다른 변수로 빼거나, 함수 체인을 끼우면 widening이 더 쉽게 발생합니다.

2) 객체 인자의 값이 리터럴이 아닌 string 으로 변함

라우팅/액션 같은 곳에서 흔한 패턴입니다.

type Route = {
  name: string;
  path: string;
};

function defineRoute<T extends Route>(route: T) {
  return route;
}

const r = defineRoute({ name: "home", path: "/" });
// 기대: name이 "home" 리터럴
// 현실: name: string, path: string 로 넓어질 수 있음

이건 as const 로 어느 정도 막을 수 있지만, as const 는 다음 단점이 있습니다.

  • 객체 전체가 readonly가 되어 downstream에서 불편할 수 있음
  • “딱 필요한 필드만 리터럴로 유지” 같은 미세 조정이 어려움

기존 해결책과 한계

as const 의 장단점

const r = defineRoute({ name: "home", path: "/" } as const);
  • 장점: 간단하고 즉시 해결
  • 단점: 객체 전체가 깊게 readonly가 되고, 타입이 지나치게 좁아져서 재사용이 불편해질 수 있음

overload 또는 보조 제네릭으로 유도하기

function pickKeys<T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
  type KeyUnion = K[number];
  const out = {} as Pick<T, KeyUnion>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

위 코드는 사실상 “TS 5.5 방식”에 가까운 형태인데, TS 5.5 이전에는 const 타입 파라미터가 없어서 as const 를 강요하거나, 호출부에서 as const 를 붙이게 만드는 식으로 끝나는 경우가 많았습니다.

TS 5.5 const type parameters란?

핵심은 이겁니다.

  • 제네릭 타입 파라미터 앞에 const 를 붙이면
  • 리터럴 타입을 최대한 보존한 채로 추론하려고 합니다.

즉, 함수 인자로 들어온 값이 리터럴이면, 제네릭을 통과하면서도 그 리터럴이 string 같은 넓은 타입으로 퍼지는 것을 줄여줍니다.

주의할 점:

  • const 타입 파라미터는 “값을 const로 만든다”가 아니라 “타입 추론을 const처럼 한다”에 가깝습니다.
  • 런타임 동작은 바뀌지 않습니다.

배열/튜플 인자에서 리터럴 유니온 유지하기

가장 체감이 큰 케이스가 “키 목록”, “이벤트 목록” 같은 배열 인자입니다.

before: 호출부에서 as const 를 강요

function onEvents<E extends string>(events: E[], handler: (e: E) => void) {
  // ...
}

onEvents(["open", "close"], (e) => {
  // e가 string으로 넓어지면 분기에서 손해
});

after: const 타입 파라미터로 고치기

function onEvents<const E extends readonly string[]>(
  events: E,
  handler: (e: E[number]) => void
) {
  // ...
}

onEvents(["open", "close"], (e) => {
  // e: "open" | "close"
  if (e === "open") {
    // ...
  }
});

포인트는 두 가지입니다.

  • Ereadonly string[] 로 받고
  • 실제 이벤트 유니온은 E[number] 로 뽑습니다.

이 패턴은 pickKeys, allowlist, enum-like 헬퍼에 그대로 재사용됩니다.

객체 인자에서도 리터럴을 보존하기

라우트 정의, 설정 스키마, 액션 정의 같은 곳에서 유용합니다.

type RouteDef = {
  name: string;
  path: string;
  method?: "GET" | "POST";
};

function defineRoute<const R extends RouteDef>(r: R) {
  return r;
}

const route = defineRoute({
  name: "home",
  path: "/",
  method: "GET",
});

// route.name: "home"
// route.path: "/"
// route.method: "GET"

여기서 중요한 실무적 이점은 다음입니다.

  • 호출부에 as const 를 붙이지 않아도 리터럴이 살아남음
  • 객체가 불필요하게 deep readonly로 굳지 않음
  • 라우트 이름 기반으로 타입 안전한 맵을 만들기 쉬워짐

실전 패턴 1: 타입 안전한 pick 유틸

pick 은 실무에서 자주 만들지만, 키 배열 때문에 타입이 깨지는 대표 사례입니다.

function pick<T, const K extends readonly (keyof T)[]>(obj: T, keys: K) {
  type KeyUnion = K[number];
  const out = {} as Pick<T, KeyUnion>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

const user = { id: 1, name: "a", role: "admin" as const };

const a = pick(user, ["id", "role"]);
// a: { id: number; role: "admin" }

이제 호출부는 그냥 배열 리터럴을 넘기기만 하면 됩니다.

실전 패턴 2: 허용된 문자열 집합으로 validator 만들기

API 입력 검증이나 쿼리 파라미터에서 많이 씁니다.

function oneOf<const V extends readonly string[]>(...values: V) {
  const set = new Set(values);
  return (x: string): x is V[number] => set.has(x);
}

const isEnv = oneOf("dev", "stage", "prod");

function boot(env: string) {
  if (!isEnv(env)) throw new Error("invalid env");
  // 여기서 env: "dev" | "stage" | "prod"
}

...values 가 튜플로 유지되면서 V[number] 가 정확한 유니온이 됩니다.

실전 패턴 3: 라우트/핸들러 매핑에서 키 추론 유지

Next.js나 API 라우터를 만들 때 흔히 “이름 기반 매핑”을 합니다.

function defineHandlers<const R extends readonly { name: string }[]>(routes: R) {
  type Names = R[number]["name"];
  return function register<const H extends Record<Names, () => void>>(handlers: H) {
    return handlers;
  };
}

const register = defineHandlers([
  { name: "home" },
  { name: "settings" },
]);

const handlers = register({
  home: () => {},
  settings: () => {},
  // other: () => {}, // 에러: 허용되지 않은 키
});

routesname 들이 리터럴로 유지되기 때문에, handlers 의 키 제약이 정확해집니다.

이런 “키 기반 타입 안전성”은 프론트에서도 중요하지만, 서버에서도 “스키마 기반 라우팅”이나 “이벤트 기반 처리”에서 큰 효과가 있습니다. 스키마 검증을 다룬 글인 OpenAI Responses API 422 스키마 검증 에러 해결 가이드처럼, 입력 스키마를 엄격히 가져갈수록 타입 쪽도 리터럴 추론이 탄탄해야 디버깅 비용이 줄어듭니다.

const type params를 쓸 때의 체크리스트

1) 배열은 readonly 로 받기

const 타입 파라미터를 쓰더라도, 배열을 그냥 string[] 로 받으면 “튜플로 유지”가 깨질 수 있습니다.

  • 권장: const T extends readonly string[]
  • 그리고 유니온은 T[number]

2) 객체도 const 로 받되, 제약 타입을 너무 빡세게 잡지 않기

function defineThing<const T extends { name: string }>(t: T) {
  return t;
}

이처럼 “필수 형태만 제약”하고 나머지는 열어두는 편이 실무에서 확장성이 좋습니다.

3) 반환 타입에 “추론 결과”를 그대로 노출하기

const 타입 파라미터로 얻은 이점을 살리려면, 반환 타입에서 그 정보를 잃지 않아야 합니다.

  • 배열이면 T[number]
  • 객체면 T["field"]
  • 맵이면 keyof T 등을 적극 사용

마이그레이션 관점: 기존 코드에 어떻게 적용할까?

  1. 리터럴 유니온이 핵심인 유틸부터 찾습니다.
  • pick, omit, select, allowlist, defineRoute, defineConfig
  • 이벤트 등록, 커맨드 등록, 라우터 등록
  1. 해당 함수의 제네릭에 const 를 붙이고, 인자 타입을 readonly 로 조정합니다.

  2. 호출부에 붙어 있던 as const 를 제거해도 타입이 유지되는지 확인합니다.

  3. 타입 테스트를 추가합니다.

// 타입 테스트 예시(컴파일 단계에서만 확인)
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
  (<T>() => T extends B ? 1 : 2)
  ? true
  : false;

type Expect<T extends true> = T;

const events = ["open", "close"] as const;

type _t1 = Expect<Equal<(typeof events)[number], "open" | "close">>;

테스트 코드에서도 부등호 기호가 들어갈 수 있으니, MDX 환경에서는 본문에 >< 가 노출되지 않도록 항상 코드 블록 안에만 두는 습관이 안전합니다.

정리

TypeScript 5.5의 const 타입 파라미터는 “리터럴 추론이 무너지는” 실무 고질병을 꽤 깔끔하게 정리해줍니다.

  • 배열/튜플 인자에서 리터럴 유니온을 유지하려면 const T extends readonly ...[]T[number] 패턴을 씁니다.
  • 객체 인자에서도 const 로 받아서 as const 의 과도한 readonly 전파 없이 리터럴을 살릴 수 있습니다.
  • 라우트/이벤트/핸들러 등록 같은 “키 기반 타입 안전성”이 필요한 곳에서 특히 효과가 큽니다.

Next.js 프로젝트에서 이런 헬퍼들이 늘어나면, 캐시/ISR 같은 런타임 이슈를 디버깅할 때도 타입이 단단한 쪽이 원인 범위를 빨리 줄여줍니다. 관련해서는 Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅도 함께 참고하면 좋습니다.

리터럴 추론이 흔들리는 지점을 const 타입 파라미터로 먼저 고정해두면, 이후의 리팩터링과 기능 추가가 훨씬 덜 무섭습니다.