Published on

TS 5.5 const 타입 파라미터로 추론 고정하기

Authors

서버와 프론트가 같은 타입을 공유하는 코드베이스에서, 제네릭 함수 하나가 리터럴 타입을 유지하느냐 못하느냐는 생산성과 안정성에 큰 차이를 만듭니다. 특히 설정 객체, 라우트 정의, 이벤트 맵, 국제화 키, SQL 쿼리 빌더 같은 영역에서는 입력이 리터럴인데도 추론 과정에서 string 또는 number로 넓어지면서 타입 안전성이 급격히 떨어지는 일이 자주 발생합니다.

TypeScript 5.5는 이런 케이스를 겨냥해 const 타입 파라미터를 도입했습니다. 핵심은 간단합니다. 제네릭 타입 파라미터 선언에 const를 붙이면, 해당 타입 파라미터에 대한 추론이 가능한 한 리터럴 형태로 고정됩니다. 즉, 호출 지점에서 굳이 as const를 남발하지 않아도, 함수 시그니처 차원에서 리터럴 보존을 강제할 수 있습니다.

이 글에서는 const 타입 파라미터가 왜 필요한지, 어떤 문제를 해결하는지, 그리고 실제로 어디에 적용하면 좋은지 실전 패턴 위주로 정리합니다.

왜 리터럴 타입이 쉽게 깨질까

TypeScript의 제네릭 추론은 “사용하기 편한 방향”으로 동작합니다. 문제는 그 편의가 곧 “넓은 타입으로의 승격”인 경우가 많다는 점입니다.

예를 들어 라우트 정의를 받아서 경로 파라미터를 추출하는 유틸을 만든다고 해봅시다.

// 경로 문자열에서 `:id` 같은 파라미터 이름을 뽑는 타입
type ParamNames<Path extends string> =
  Path extends `${string}:${infer P}/${infer Rest}`
    ? P | ParamNames<`/${Rest}`>
    : Path extends `${string}:${infer P}`
      ? P
      : never;

type ParamsOf<Path extends string> = {
  [K in ParamNames<Path>]: string;
};

function makeRoute<Path extends string>(path: Path) {
  return {
    path,
    build(params: ParamsOf<Path>) {
      return path; // 예시 단순화
    },
  };
}

const r = makeRoute("/users/:id");
// 기대: r.build({ id: "1" })

겉보기엔 잘 될 것 같지만, 실제 코드가 조금만 복잡해져도 Pathstring으로 넓어지는 순간이 생깁니다. 예를 들어 다음처럼 객체로 묶거나, 중간 변수를 거치거나, 오버로드/헬퍼를 추가하는 과정에서 리터럴이 깨지면 ParamsOf<string>이 되어버리고, 결국 build의 인자가 {}처럼 무력화됩니다.

이 문제를 막기 위해 기존에는 호출부에서 as const를 붙이거나, satisfies를 사용해 리터럴을 유지하는 테크닉을 썼습니다. satisfies 관련 내용은 아래 글도 함께 보면 맥락이 이어집니다.

하지만 호출부에 매번 보일러플레이트를 강제하는 방식은 팀 규모가 커질수록 유지보수가 어려워집니다. 이때 유용한 것이 TypeScript 5.5의 const 타입 파라미터입니다.

TS 5.5 const 타입 파라미터란

문법은 다음처럼 제네릭 타입 파라미터 앞에 const를 붙입니다.

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

의미는 “T를 추론할 때 가능한 한 리터럴 타입으로 고정하라”입니다. 특히 객체/배열/튜플/문자열 리터럴에서 효과가 큽니다.

중요한 포인트는 다음 두 가지입니다.

  1. 호출부의 as const 의존도를 줄인다

  2. 라이브러리 API가 리터럴 보존을 책임지게 만든다

즉, 타입 안정성을 호출자가 아니라 함수 시그니처가 보장하도록 방향을 바꿀 수 있습니다.

예제 1: 라우트 정의에서 파라미터 추론 고정

앞서 만든 라우트 예제를 const 타입 파라미터로 바꿔보겠습니다.

type ParamNames<Path extends string> =
  Path extends `${string}:${infer P}/${infer Rest}`
    ? P | ParamNames<`/${Rest}`>
    : Path extends `${string}:${infer P}`
      ? P
      : never;

type ParamsOf<Path extends string> = {
  [K in ParamNames<Path>]: string;
};

function makeRoute<const Path extends string>(path: Path) {
  return {
    path,
    build(params: ParamsOf<Path>) {
      return path;
    },
  };
}

const r1 = makeRoute("/users/:id");
r1.build({ id: "1" });

// @ts-expect-error
r1.build({});

// @ts-expect-error
r1.build({ id: "1", extra: "x" });

이제 Path가 리터럴로 고정되기 때문에, build의 인자 타입이 안정적으로 유지됩니다. 호출부에서 makeRoute("/users/:id" as const) 같은 패턴을 강요하지 않아도 됩니다.

예제 2: 이벤트 맵 정의에서 키 리터럴 유지

프론트엔드에서 자주 쓰는 패턴 중 하나가 “이벤트 이름과 payload 타입을 매핑한 객체”입니다.

type HandlerMap = Record<string, (payload: any) => void>;

function createEmitter(map: HandlerMap) {
  return {
    emit(name: keyof typeof map, payload: any) {
      map[name](payload);
    },
  };
}

const emitter = createEmitter({
  userCreated: (p: { id: string }) => {},
  userDeleted: (p: { id: string }) => {},
});

위 코드는 mapHandlerMap으로 받아지면서 키 정보가 약해지기 쉽고, payload 타입도 any로 뭉개질 위험이 큽니다. const 타입 파라미터로 “입력 객체의 형태를 그대로” 추론하게 만들면 개선됩니다.

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

function createEmitter<const M extends Record<string, AnyFn>>(map: M) {
  return {
    emit<K extends keyof M>(name: K, payload: Parameters<M[K]>[0]) {
      map[name](payload);
    },
  };
}

const emitter2 = createEmitter({
  userCreated: (p: { id: string }) => {},
  userDeleted: (p: { id: string }) => {},
});

emitter2.emit("userCreated", { id: "1" });

// @ts-expect-error
emitter2.emit("userCreated", { id: 1 });

// @ts-expect-error
emitter2.emit("unknown", { id: "1" });

여기서 핵심은 Mconst로 받아 키 리터럴이 깨지지 않도록 하고, emit에서 K로 키를 좁힌 다음 Parameters<M[K]>로 payload를 정확히 연결하는 것입니다.

예제 3: 튜플 기반 API에서 길이와 순서 보존

배열을 받는 API는 추론이 조금만 흔들려도 string[] 같은 형태로 넓어져 “튜플 길이” 정보가 사라집니다. 예를 들어 “정렬 키 목록”을 받아서 그 키들만 허용하는 쿼리 빌더를 만든다고 합시다.

function defineSortKeys<const K extends readonly string[]>(...keys: K) {
  return keys;
}

const keys = defineSortKeys("createdAt", "name");
// 타입: readonly ["createdAt", "name"]

type SortKey = (typeof keys)[number];
// "createdAt" | "name"

function orderBy(key: SortKey) {
  return key;
}

orderBy("name");
// @ts-expect-error
orderBy("id");

이 패턴은 라우팅, 권한 스코프, feature flag 키, i18n 키 목록 등 “고정된 문자열 집합”을 다루는 곳에서 특히 강력합니다.

as const vs const 타입 파라미터

둘 다 리터럴 보존을 위한 도구지만, 책임 소재가 다릅니다.

  • as const: 호출자가 “이 값은 리터럴로 취급해”라고 명시
  • const 타입 파라미터: 라이브러리/함수 작성자가 “이 API는 리터럴을 보존할게”라고 보장

실무에서는 다음 기준으로 선택하면 편합니다.

  • 단발성 코드, 로컬 스코프에서만 쓰는 값이면 as const도 충분
  • 여러 팀/여러 모듈이 재사용하는 유틸 함수라면 const 타입 파라미터가 유지보수에 유리

특히 공용 유틸은 호출부가 수십, 수백 군데로 늘어나기 때문에, 호출부에 as const를 강제하는 순간 기술 부채가 빠르게 쌓입니다.

주의사항과 한계

1) “항상” 리터럴이 되는 것은 아니다

const 타입 파라미터는 추론을 고정하는 힌트이지만, 입력 자체가 이미 넓은 타입이면 결과도 넓습니다.

let p = "/users/:id"; // 타입: string
const r = makeRoute(p);
// Path는 string이 될 수밖에 없음

이 경우는 p 선언을 const로 하거나, p에 리터럴 타입을 부여해야 합니다.

const p = "/users/:id";
const r = makeRoute(p);

2) 제네릭이 여러 개일 때 “어디를 고정할지”가 중요

무작정 모든 타입 파라미터에 const를 붙이면, 오히려 타입이 너무 구체적으로 고정되어 사용성이 떨어질 수 있습니다. 보통은 “입력 리터럴을 보존해야 하는 축”에만 적용하는 게 좋습니다.

예를 들어 createEmitter에서는 M을 고정하는 것이 핵심이고, K는 호출마다 달라지는 키이므로 일반 제네릭으로 두는 편이 자연스럽습니다.

3) satisfies와의 조합이 여전히 유용

const 타입 파라미터는 함수 경계에서 강력하지만, 객체 리터럴을 미리 변수로 빼서 검증하고 싶을 때는 satisfies가 더 읽기 좋을 때가 있습니다.

const eventMap = {
  userCreated: (p: { id: string }) => {},
  userDeleted: (p: { id: string }) => {},
} satisfies Record<string, (p: { id: string }) => void>;

const emitter = createEmitter(eventMap);

이 방식은 “형태는 검증하되, 구체 타입은 유지”라는 목적을 잘 달성합니다.

언제 도입하면 체감이 큰가

다음 체크리스트에 해당하면 const 타입 파라미터 도입 효과가 큽니다.

  • 문자열 리터럴 기반 DSL을 만든다 (라우트, 쿼리, 권한 스코프)
  • 이벤트 이름과 payload를 연결한다
  • 튜플 길이/순서를 타입으로 활용한다
  • 호출부 as const가 반복적으로 등장한다
  • 공용 유틸인데 호출부가 계속 타입 이슈를 겪는다

TypeScript 5.5에는 리소스 관리와 관련된 await using, Disposable 같은 기능도 함께 들어왔는데, 런타임 안정성과 타입 안정성을 동시에 끌어올리는 흐름으로 보면 좋습니다.

마이그레이션 팁

  1. 먼저 as const가 자주 붙는 유틸 함수를 찾습니다.

  2. 그 함수의 “리터럴을 보존해야 하는 입력”에만 const 타입 파라미터를 적용합니다.

  3. 호출부에서 as const를 제거해도 타입이 유지되는지 확인합니다.

  4. 타입이 너무 구체화되어 오히려 불편해진 지점이 있으면 const를 제거하거나, 입력 타입을 string처럼 일부러 넓히는 별도 오버로드를 제공합니다.

예를 들어 “리터럴 경로를 주면 강한 타입, 그냥 string을 주면 약한 타입”을 제공하는 식입니다.

function makeRoute(path: string): { path: string; build(params: Record<string, string>): string };
function makeRoute<const Path extends string>(path: Path): { path: Path; build(params: ParamsOf<Path>): string };
function makeRoute(path: string) {
  return {
    path,
    build(_params: Record<string, string>) {
      return path;
    },
  };
}

이렇게 하면 프레임워크 내부처럼 동적 문자열이 들어오는 곳도 수용하면서, 앱 코드에서 리터럴을 쓰는 대부분의 경우에는 강한 타입을 얻을 수 있습니다.

정리

TypeScript 5.5의 const 타입 파라미터는 “리터럴 기반 API를 설계할 때, 추론이 넓어지는 문제를 함수 시그니처에서 차단”하는 도구입니다. 호출부의 as const를 줄이고, 공용 유틸의 타입 안정성을 높이며, 라우팅·이벤트·튜플 DSL 같은 영역에서 특히 큰 효과를 냅니다.

실무에서는 먼저 반복되는 as const를 지우는 방향으로 적용해보면 체감이 빠릅니다. 리터럴을 보존해야 하는 축만 신중하게 고정하고, 동적 입력도 필요한 곳에는 오버로드로 탈출구를 제공하면, 사용성과 안정성을 함께 가져갈 수 있습니다.