Published on

TS 5.5+ const type params로 추론 고정하기

Authors

타입스크립트에서 제네릭은 보통 "값을 넣으면 타입이 알아서 따라온다"는 장점이 있습니다. 그런데 실무에서는 이 추론이 너무 쉽게 넓어져서, 리터럴이 string이나 number로 뭉개지는 순간이 자주 생깁니다. 예를 들어 라우트 정의, 이벤트 이름, i18n 키, 상태 머신 전이, API 엔드포인트 맵처럼 "문자열 리터럴 자체"가 곧 계약인 구조에서는 추론이 넓어지는 것이 곧 타입 안정성 손실입니다.

TS 5.5+에서 도입된 const type params는 이런 상황에서 제네릭 타입 파라미터의 추론을 리터럴 중심으로 고정하는 도구입니다. 즉, 호출자가 as const를 매번 붙이지 않아도, 라이브러리나 유틸 함수가 "리터럴을 리터럴로" 받도록 강제할 수 있습니다.

아래에서는 왜 문제가 생기는지, const type params가 무엇을 바꾸는지, 그리고 실무에서 바로 써먹을 수 있는 패턴을 코드로 정리합니다. (추론을 안정화하는 다른 도구인 satisfies와의 조합도 함께 다룹니다.)

관련해서 satisfies로 추론이 깨지는 케이스를 따로 정리한 글도 참고하면 흐름이 이어집니다: TS 5.5+ satisfies로 타입추론 깨짐 잡는 법

왜 제네릭 추론이 넓어지는가

타입스크립트의 제네릭 추론은 "안전한 방향"으로 가기 위해 종종 리터럴을 넓혀서 일반 타입으로 만듭니다. 대표적으로 아래 같은 패턴에서 문제가 터집니다.

예시 1: 문자열 배열을 받아 유니온을 만들고 싶은데 string이 되어버림

function makeUnion<T extends readonly string[]>(xs: T) {
  type U = T[number];
  return xs;
}

const xs = makeUnion(["GET", "POST"]);
// 기대: ("GET" | "POST")
// 현실: string 로 넓어질 수 있음 (상황/버전에 따라)

물론 호출부에서 as const를 붙이면 해결됩니다.

const xs2 = makeUnion(["GET", "POST"] as const);

하지만 매 호출마다 as const를 요구하는 API는 사용성이 떨어집니다. 특히 팀 단위로 코드가 커지면 "어떤 함수는 as const가 필요하고 어떤 함수는 필요 없다"가 혼란을 만들고, 결국 타입이 뭉개진 채로 방치되기 쉽습니다.

예시 2: 객체 맵에서 키를 리터럴로 유지하고 싶은데 string으로 붕괴

function keysOf<T extends object>(obj: T) {
  return Object.keys(obj);
}

const k = keysOf({
  home: "/",
  about: "/about",
});

// k: string[]
// 기대는 ("home" | "about")[] 같은 형태

여기서는 런타임 API인 Object.keys 자체가 string[]을 반환하기 때문에, 더 세밀한 래핑이 필요합니다. 이런 래핑 함수에서 const type params가 유용해집니다.

const type params란 무엇인가

TS 5.5+의 const type params는 제네릭 선언에서 타입 파라미터 앞에 const를 붙여 추론 시 리터럴 타입을 더 강하게 보존하도록 지시합니다.

형태는 다음과 같습니다.

function f<const T>(arg: T): T {
  return arg;
}

핵심은 두 가지입니다.

  1. 호출부가 굳이 as const를 쓰지 않아도, 함수가 리터럴을 "리터럴"로 받게 만들 수 있습니다.
  2. 특히 배열/튜플/객체 리터럴에서 타입이 넓어지는 것을 방지하는 데 효과적입니다.

이 기능은 "라이브러리 작성자" 관점에서 더 빛납니다. 즉, 팀 공용 유틸이나 사내 SDK에서 추론을 안정화해두면, 소비자는 별 생각 없이도 정확한 타입을 얻게 됩니다.

패턴 1: 문자열 튜플을 받아 유니온을 만드는 헬퍼

가장 많이 쓰는 패턴은 "리터럴 목록을 받아 유니온으로"입니다.

export function literalList<const T extends readonly string[]>(...xs: T) {
  return xs;
}

const methods = literalList("GET", "POST", "PUT");
// methods: readonly ["GET", "POST", "PUT"]

type Method = (typeof methods)[number];
// Method: "GET" | "POST" | "PUT"

여기서 포인트는 ...xs로 받는 것입니다. 배열 리터럴을 통째로 받는 것보다, 가변 인자로 받으면 튜플 추론이 더 자연스럽게 일어납니다.

이 패턴은 라우팅, 권한 스코프, 이벤트 이름 목록 등에서 아주 유용합니다.

패턴 2: 라우트 정의 API에서 경로 리터럴 고정

프론트엔드에서 라우팅 정의는 리터럴을 잃으면 바로 타입 세이프티가 무너집니다.

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

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

const routes = defineRoutes([
  { name: "home", path: "/" },
  { name: "post", path: "/posts/:id" },
]);

type RouteName = (typeof routes)[number]["name"];
// "home" | "post"

type RoutePath = (typeof routes)[number]["path"];
// "/" | "/posts/:id"

만약 const가 없다면, 구현/버전/상황에 따라 name이나 pathstring으로 넓어져 RouteName이 무의미해질 수 있습니다. const type params는 이런 "정의 API"에서 특히 가치가 큽니다.

패턴 3: Object.keys를 타입 안전하게 감싸기

앞서 본 Object.keys 문제는 런타임 반환 타입이 string[]이라 생깁니다. 아래처럼 래핑하면서 const type params를 사용하면, 입력 객체의 키를 리터럴 유니온으로 얻는 기반을 만들 수 있습니다.

export function typedKeys<const T extends Record<PropertyKey, unknown>>(obj: T) {
  return Object.keys(obj) as Array<Extract<keyof T, string>>;
}

const m = {
  home: "/",
  about: "/about",
};

const ks = typedKeys(m);
// ("home" | "about")[]

주의할 점은 캐스팅이 들어간다는 사실입니다. Object.keys는 심볼 키를 반환하지 않고, 숫자 키도 문자열로 변환합니다. 그래서 위 예시처럼 Extract로 문자열 키만 뽑는 편이 현실적입니다.

이 래핑은 설정 맵, feature flag 맵, i18n 리소스 맵에서 자주 씁니다.

패턴 4: 이벤트 버스 API에서 이벤트 이름과 payload를 함께 고정

이벤트 버스는 "이벤트 이름"이 리터럴로 유지되어야 onemit이 연결됩니다.

type Handler<P> = (payload: P) => void;

type EventMap = Record<string, unknown>;

function createBus<const E extends EventMap>() {
  const handlers = new Map<string, Set<Handler<any>>>();

  return {
    on<K extends keyof E>(event: K, fn: Handler<E[K]>) {
      const key = String(event);
      const set = handlers.get(key) ?? new Set();
      set.add(fn);
      handlers.set(key, set);
      return () => set.delete(fn);
    },
    emit<K extends keyof E>(event: K, payload: E[K]) {
      const key = String(event);
      handlers.get(key)?.forEach((fn) => fn(payload));
    },
  };
}

const bus = createBus<{
  "user:login": { id: string };
  "user:logout": undefined;
}>();

bus.on("user:login", (p) => {
  p.id;
});

bus.emit("user:logout", undefined);

여기서 createBus의 제네릭 파라미터에 const를 붙이면, 이벤트 키를 리터럴로 더 안정적으로 유지하는 데 도움이 됩니다. 특히 이벤트 맵을 다른 곳에서 조합하거나, 헬퍼를 통해 생성하는 경우에 효과가 커집니다.

const type params와 satisfies의 관계

const type params가 "함수 경계에서의 추론 고정"이라면, satisfies는 "값이 특정 타입을 만족하는지 검사하되, 값 자체의 더 구체적인 타입은 유지"하려는 목적에 가깝습니다.

두 기능은 경쟁 관계가 아니라 조합 관계입니다.

예시: 라우트 객체가 특정 스키마를 만족하는지 검증하면서 리터럴 유지

type Route = {
  name: string;
  path: `/${string}`;
};

const routeTable = {
  home: { name: "home", path: "/" },
  about: { name: "about", path: "/about" },
} satisfies Record<string, Route>;

export function defineRouteTable<const T extends Record<string, Route>>(t: T) {
  return t;
}

const routes2 = defineRouteTable(routeTable);
// routes2.home.path 는 "/" 같은 리터럴로 유지될 가능성이 커짐
  • satisfies로 "각 항목이 Route 형태인지"를 검증
  • defineRouteTable에서 const T로 "리터럴을 잃지 않게" 고정

const type params는 특히 "함수에 인라인 리터럴을 넘길 때" 효과가 체감됩니다. satisfies는 "선언된 상수의 형태 검증"에서 강점이 큽니다.

더 많은 satisfies 실무 패턴은 다음 글도 같이 보면 좋습니다: TS 5.x satisfies로 타입 좁히기 실무 패턴

언제 const type params를 쓰면 좋은가

다음 조건 중 2개 이상이면 도입 가치가 큽니다.

  1. 호출부에서 as const를 반복해서 쓰고 있다
  2. "문자열 리터럴"이나 "튜플 순서"가 곧 계약이다 (라우트, 이벤트, 커맨드, 권한)
  3. 제네릭 유틸을 팀 공용으로 배포하고 있고, 사용성 때문에 타입 안전이 자주 깨진다
  4. 리터럴이 string으로 넓어져서 keyof나 인덱스 접근 타입이 무의미해진 적이 있다

반대로, 단순히 "어차피 string이면 된다" 같은 API에서는 과도한 고정이 오히려 불편할 수 있습니다. 예를 들어 입력을 자유롭게 바꿔야 하는 폼 값 처리나, 외부 입력을 즉시 정규화하는 함수라면 리터럴 고정이 큰 이득이 아닐 수 있습니다.

마이그레이션 팁과 주의사항

1) TS 버전 게이트를 명확히

const type params는 TS 5.5+ 기능이므로, 라이브러리나 모노레포라면 최소 버전을 명확히 해야 합니다.

  • 패키지의 devDependenciestypescript 버전을 올리고
  • CI에서 tsc --noEmit으로 타입 체크를 고정

2) 리터럴 고정은 "API 표면"에만

내부 구현까지 전부 const로 고정하려고 하면 타입이 지나치게 구체화되어 리팩터링이 어려워질 수 있습니다. 보통은 다음처럼 경계에서만 고정하고 내부에서는 적당히 일반화합니다.

function defineConfig<const T extends { env: readonly string[] }>(cfg: T) {
  // 내부에서는 필요하면 일반 타입으로 변환해서 사용
  const envs: string[] = [...cfg.env];
  return { ...cfg, envs };
}

3) 런타임 동작이 타입을 보장하지는 않는다

Object.keys처럼 런타임 반환이 타입과 어긋나는 API는 캐스팅이 필요합니다. 이때 const type params는 "입력 추론"을 도와줄 뿐, 런타임의 불완전함을 자동으로 해결하지는 않습니다.

정리

TS 5.5+의 const type params는 제네릭 추론이 넓어져 리터럴 계약이 무너지는 문제를 해결하는 데 매우 실용적입니다.

  • 호출부의 as const 의존도를 줄이고
  • 라우트/이벤트/설정 같은 "정의형 API"에서 리터럴을 안정적으로 유지하며
  • satisfies와 함께 쓰면 "스키마 검증"과 "리터럴 보존"을 동시에 달성할 수 있습니다.

실무에서 타입 안정성은 결국 "좋은 API 경계"에서 결정됩니다. const type params는 그 경계를 더 단단하게 만들어주는, 비교적 작은 변화로 큰 효과를 내는 기능입니다.