Published on

TS 5.5+ const 타입 파라미터로 리터럴 추론 고정

Authors

서로 다른 모듈을 오가며 함수를 호출할 때, 우리가 기대하는 “리터럴 타입”이 어느 순간 string, number, boolean 같은 넓은 타입으로 바뀌어 버리는 경험이 잦습니다. 특히 옵션 객체, 라우팅 파라미터, 이벤트 이름, 쿼리 키 같은 값들은 리터럴로 유지될수록 안전한데, 제네릭 함수 경계에서 추론이 약해지면 곧바로 타입 안정성이 떨어집니다.

TypeScript 5.5+에서 도입된 const 타입 파라미터는 이런 상황에서 리터럴 추론을 더 강하게 고정하는 도구입니다. 핵심은 “호출자가 넘긴 값의 형태를 가능한 한 그대로(리터럴/readonly/튜플) 타입으로 캡처”하는 데 있습니다.

이 글에서는 const 타입 파라미터가 무엇을 해결하는지, 기존에 쓰던 as const/오버로드/유틸 타입과 무엇이 다른지, 그리고 실무에서 가장 많이 쓰는 패턴을 코드로 정리합니다.

Next.js App Router에서 라우트 세그먼트나 캐시 키를 문자열 리터럴로 유지하면 리팩터링 안전성이 크게 올라갑니다. 관련해서는 Next.js App Router 캐시 무효화 7가지 정리도 함께 보면, “키를 타입으로 고정”하는 필요성이 더 와닿습니다.

왜 리터럴 추론이 무너질까

TypeScript의 추론은 대체로 “안전한 방향”으로 동작합니다. 함수 인자에서 리터럴을 받더라도, 제네릭이나 구조적 타이핑을 거치며 값이 widening(확장)되어 "GET"string으로, ["a", "b"]string[]로, { mode: "dark" }{ mode: string }처럼 바뀌는 일이 생깁니다.

대표적으로 다음과 같은 형태입니다.

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

const a = id("GET");
// a: "GET" (대체로 유지)

function pick<T extends { mode: string }>(x: T) {
  return x.mode;
}

const m = pick({ mode: "dark" });
// m: string (리터럴 "dark"가 사라짐)

여기서 pickT{ mode: string }로 제한해두었기 때문에, mode를 꺼내는 순간 string으로 보수적으로 처리됩니다. 이런 패턴은 옵션 객체를 받는 API에서 매우 흔합니다.

TS 5.5+ const 타입 파라미터란

const 타입 파라미터는 제네릭 선언에서 다음처럼 씁니다.

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

이때 T의 추론이 “가능한 한 리터럴/튜플/readonly 형태를 유지”하도록 유도됩니다. 즉, 호출자가 넘긴 값의 구체성이 함수 내부로 더 잘 전달됩니다.

주의할 점은, 이것이 런타임의 const와는 무관하다는 것입니다. 오직 타입 추론 규칙에 영향을 주는 문법입니다.

as const와의 차이: 호출자 부담을 줄인다

기존에는 호출자가 매번 as const를 붙여 리터럴을 강제로 고정하는 경우가 많았습니다.

function makeRoute(parts: readonly string[]) {
  return parts.join("/");
}

const r1 = makeRoute(["users", "me"]);
// parts: string[]로 추론되기 쉬움

const r2 = makeRoute(["users", "me"] as const);
// parts: readonly ["users", "me"]

as const는 강력하지만, 매 호출 지점에 붙여야 해서 번거롭고 팀 컨벤션으로 강제하기도 어렵습니다.

const 타입 파라미터를 쓰면, 호출자 부담을 줄이면서도 함수가 더 많은 정보를 캡처할 수 있습니다.

function makeRoute<const Parts extends readonly string[]>(parts: Parts) {
  return parts.join("/");
}

const r = makeRoute(["users", "me"]);
// parts: readonly ["users", "me"] 로 더 잘 유지

패턴 1: 이벤트/커맨드 이름을 리터럴로 고정

이벤트 이름이나 커맨드 문자열은 리터럴로 유지되는 순간 오타가 컴파일 타임에 잡힙니다.

type HandlerMap = {
  "user.created": (id: string) => void;
  "user.deleted": (id: string) => void;
};

function on<const K extends keyof HandlerMap>(
  event: K,
  handler: HandlerMap[K]
) {
  // register...
}

on("user.created", (id) => {
  id.toUpperCase();
});

// on("user.cretaed", () => {});
// 오타는 컴파일 에러

여기서 const K가 없더라도 동작하는 경우가 많지만, 이벤트 값이 다른 연산을 거치거나 래핑 함수가 생기면 리터럴이 흐려지기 쉽습니다. 특히 “옵션 객체에 이벤트 이름을 넣는” 형태가 되면 차이가 커집니다.

function register<const Opt extends { event: keyof HandlerMap }>(opt: Opt) {
  return opt.event;
}

const e = register({ event: "user.created" });
// e: "user.created" 를 기대

이런 구조에서 const 타입 파라미터는 “옵션 객체 내부의 리터럴”을 더 잘 보존합니다.

패턴 2: 튜플 기반 API에서 인자 목록을 보존

리스트/배열 기반 API는 string[]로 뭉개지면 타입 정보가 사라집니다. 예를 들어 허용된 컬럼 목록을 받고, 그 컬럼들만 pick하는 유틸을 만든다고 해봅시다.

type User = {
  id: string;
  name: string;
  email: string;
};

function pickKeys<const K extends readonly (keyof User)[]>(keys: K) {
  return keys;
}

const keys = pickKeys(["id", "email"]);
// keys: readonly ["id", "email"]

keys를 바탕으로 결과 타입을 만들 때도 튜플이 유지되면 훨씬 정교하게 만들 수 있습니다.

type PickByKeys<T, K extends readonly (keyof T)[]> = Pick<T, K[number]>;

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

const u: User = { id: "1", name: "n", email: "e" };
const partial = pickFrom(u, ["id", "email"]);
// partial: { id: string; email: string }

만약 Kstring[]로 추론되면 K[number]string이 되어 Pick<T, string> 같은 무의미한 타입으로 무너질 수 있습니다.

패턴 3: 옵션 객체에서 리터럴이 사라지는 문제 해결

실무에서 가장 흔한 케이스는 “옵션 객체”입니다.

type Mode = "light" | "dark";

type Options = {
  mode: Mode;
  retry: 0 | 1 | 2;
};

function createClient(options: Options) {
  return options;
}

const c = createClient({ mode: "dark", retry: 2 });
// 여기서는 대체로 리터럴이 유지될 수 있지만,
// 래핑 함수나 제네릭이 끼면 쉽게 widening 됨

래핑 함수가 생기면 문제가 커집니다.

function wrap<T extends Options>(opt: T) {
  return opt;
}

const w = wrap({ mode: "dark", retry: 2 });
// w.mode: Mode ("dark"가 아니라)
// w.retry: 0 | 1 | 2 (2가 아니라)

이때 const 타입 파라미터로 “넘어온 리터럴을 최대한 보존”하게 만들 수 있습니다.

function wrap<const T extends Options>(opt: T) {
  return opt;
}

const w2 = wrap({ mode: "dark", retry: 2 });
// w2.mode: "dark"
// w2.retry: 2

이 차이는 이후 분기 로직에서 특히 유용합니다.

function boot<const T extends Options>(opt: T) {
  if (opt.mode === "dark") {
    // opt.mode가 "dark"로 고정되어 있으면
    // 이 분기가 "항상 참" 같은 형태로도 정적 분석이 가능
  }
}

패턴 4: 문자열 템플릿 키를 안전하게 만들기

예를 들어 API 엔드포인트를 "GET /users" 같은 문자열로 표현한다고 해봅시다.

type Method = "GET" | "POST";

type Endpoint = `${Method} ${string}`;

function defineEndpoint<const E extends Endpoint>(e: E) {
  return e;
}

const ep = defineEndpoint("GET /users");
// ep: "GET /users"

이렇게 캡처한 리터럴을 기반으로 라우팅 테이블을 만들거나, 키-값 매핑을 만들 때 타입이 크게 강화됩니다.

언제 const 타입 파라미터가 특히 효과적인가

다음 중 하나라도 해당하면 도입 효과가 큽니다.

  • 튜플 길이/원소 리터럴이 중요한 API: (["a", "b"]) 같은 입력
  • 옵션 객체의 특정 필드가 분기 조건/키가 되는 API: { strategy: "jwt" }
  • 키 목록을 받아 결과 타입을 계산하는 유틸: Pick, Omit, Record 조합
  • “정확한 문자열”이 계약인 곳: 이벤트 이름, 액션 타입, 쿼리 키

반대로, 단순히 값을 받아 그대로 반환하는 함수에서는 체감이 적을 수 있습니다.

도입 시 주의점과 트레이드오프

1) 타입이 너무 구체적으로 고정될 수 있다

리터럴이 고정되는 것은 장점이지만, 때로는 “너무 구체적”이라 재사용성이 떨어질 수 있습니다.

예를 들어 retry: 2가 고정되면, 이후 코드에서 retrynumber로 취급하고 싶을 때 불편할 수 있습니다. 이 경우에는 반환 타입에서 widening을 의도적으로 적용하는 방식이 좋습니다.

type Widen<T> =
  T extends string ? string :
  T extends number ? number :
  T extends boolean ? boolean :
  T;

function wrapAndWiden<const T extends Options>(opt: T): { mode: Mode; retry: number } {
  return { mode: opt.mode, retry: opt.retry };
}

2) satisfies와 역할이 다르다

const 타입 파라미터는 “추론을 고정”하는 쪽이고, satisfies는 “값이 어떤 타입을 만족하는지 검사하되, 값의 구체 타입은 유지”하는 데 강합니다.

둘은 경쟁 관계가 아니라 조합 관계입니다.

type Config = {
  mode: "light" | "dark";
  retry: 0 | 1 | 2;
};

const cfg = {
  mode: "dark",
  retry: 2,
} satisfies Config;
// cfg.mode: "dark", cfg.retry: 2 (유지)

function useConfig<const T extends Config>(c: T) {
  return c;
}

useConfig(cfg);

3) 라이브러리 API 설계에서 “호출자 경험”을 먼저 보라

const 타입 파라미터는 라이브러리/공용 유틸에서 특히 빛납니다. 호출자가 as const를 매번 붙이지 않게 만드는 것이 핵심 가치입니다.

Git 설정을 팀에 전파할 때 “개인 실수 여지를 줄이는 자동화”가 중요한 것처럼, 타입 설계도 “호출자가 실수하지 않도록 기본값을 안전하게” 잡는 게 중요합니다. 비슷한 결로, 반복 충돌 해결을 자동화하는 Git 설정은 Git rebase 충돌 자동 해결 - rerere 실무 설정 글이 참고가 됩니다.

실무 예제: 쿼리 키 빌더를 안전하게 만들기

프론트엔드에서 쿼리 키를 배열로 만들고, 그 키를 캐시/무효화에 사용한다고 해봅시다.

type QueryKey = readonly unknown[];

function key<const K extends QueryKey>(...parts: K) {
  return parts;
}

const userKey = key("user", { id: 1 });
// userKey: readonly ["user", { readonly id: 1 }]

이 키를 기반으로 무효화 API를 만들 때, 키의 첫 원소를 리터럴로 유지하면 실수 방지가 됩니다.

function invalidate<const K extends QueryKey>(k: K) {
  // cache.invalidate(k)
}

invalidate(userKey);

만약 이런 키가 unknown[]string[]로 뭉개지면, “키가 정확히 무엇인지”가 타입 레벨에서 사라지고 리팩터링 시에 취약해집니다.

마이그레이션 가이드: 어디부터 바꿀까

  1. 팀에서 자주 쓰는 유틸/헬퍼(키 빌더, 라우트 빌더, 이벤트 등록 함수)부터 적용
  2. 옵션 객체를 제네릭으로 받는 함수에서 const 타입 파라미터를 우선 검토
  3. 타입이 너무 구체적으로 고정되어 불편한 지점은 반환 타입에서 widening을 적용
  4. 호출부에 흩어진 as const를 줄이되, “상수 테이블” 같은 곳에서는 satisfies를 병행

정리

TypeScript 5.5+의 const 타입 파라미터는 “함수 경계에서 리터럴 추론을 고정”해, 튜플/옵션 객체/문자열 키 기반 API의 타입 안정성을 크게 올립니다. 특히 라이브러리나 공용 유틸을 설계할 때 호출자 부담을 줄이면서도 더 정교한 타입을 제공할 수 있습니다.

리터럴 고정이 항상 정답은 아니므로, 과도하게 구체화되어 재사용성이 떨어지는 경우에는 반환 타입에서 의도적으로 widening을 적용하세요. satisfies와도 충돌하지 않으며, 함께 쓰면 “검증 + 구체 타입 유지 + 함수 경계 보존”을 동시에 달성할 수 있습니다.