Published on

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

Authors

서버/프론트 공통 유틸을 만들다 보면 “호출 지점에서 넘긴 값의 리터럴 타입을 그대로 유지하고 싶다”는 요구가 자주 생깁니다. 예를 들어 라우트 목록, 이벤트 이름 목록, 권한 스코프 목록처럼 값 자체가 스키마 역할을 하는 데이터는 "user.created" 같은 리터럴이 string으로 넓어지는 순간 타입 안정성이 급격히 떨어집니다.

TypeScript는 원래도 리터럴 추론을 꽤 잘 해주지만, 제네릭 함수의 매개변수로 들어오는 순간 추론이 넓어지거나(특히 배열), 혹은 as const를 매번 강제해야 하는 상황이 생깁니다. TS 5.5+에서 도입된 const 타입 파라미터는 이런 문제를 “함수 시그니처 차원에서” 해결해 줍니다.

이 글에서는 const 타입 파라미터가 무엇인지, 어디에 쓰면 좋은지, 기존의 as const / 오버로드 / satisfies와 어떤 관계인지까지 실전 예제로 정리합니다. satisfies를 통한 추론-검증 동시 해결은 아래 글도 함께 보면 맥락이 더 잘 이어집니다.

const 타입 파라미터란

const 타입 파라미터는 제네릭 타입 파라미터 선언에 const를 붙여 추론 결과를 가능한 한 리터럴(좁은 타입)로 유지하도록 지시합니다.

형태는 아래처럼 생깁니다.

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

핵심 효과는 다음과 같습니다.

  • 배열 리터럴을 넘길 때 string[]로 넓어지기 쉬운 상황에서 readonly ["a", "b"] 같은 튜플/리터럴을 더 잘 유지
  • 객체 리터럴의 프로퍼티가 string으로 넓어지는 경우를 줄이고, 가능한 리터럴로 유지
  • 호출자에게 as const를 매번 강요하지 않고도 “값 기반 API”를 만들기 쉬움

주의할 점도 있습니다.

  • const는 “무조건 튜플로 만들어라”가 아니라, 추론을 더 좁게 유지하려는 힌트입니다. 컨텍스트 타입, 매개변수 타입 선언 방식에 따라 결과가 달라질 수 있습니다.
  • 리터럴 고정이 과하면 재사용성이 떨어질 수 있으니, “정말 값이 스키마인 부분”에만 쓰는 게 좋습니다.

왜 기존 방식만으로는 부족했나

문제 1: 배열 인자에서 유니온이 풀려버림

이벤트 이름을 배열로 받고, 그 유니온을 뽑아 타입으로 쓰고 싶다고 해봅시다.

function makeEmitter<T extends string>(events: T[]) {
  return {
    on(event: T, handler: () => void) {
      // ...
    },
  };
}

const emitter = makeEmitter(["created", "deleted"]);
// 기대: T = "created" | "deleted"
// 현실: T = string  (상황에 따라 넓어짐)

호출부에서 as const를 붙이면 해결되지만, 라이브러리/유틸 관점에서는 그게 늘 최선은 아닙니다.

const emitter2 = makeEmitter(["created", "deleted"] as const);

문제 2: 오브젝트 리터럴에서도 값이 넓어짐

function defineRoutes<T extends Record<string, string>>(routes: T) {
  return routes;
}

const routes = defineRoutes({
  home: "/",
  user: "/users/:id",
});

// routes.user가 "/users/:id" 리터럴로 남길 기대가 있지만
// 종종 string으로 넓어지는 설계가 나옵니다.

여기서도 as const로 고정할 수는 있지만, 호출자 경험이 나빠지고 “왜 필요한지” 설명 비용이 생깁니다.

const 타입 파라미터로 추론을 고정하는 패턴

1) 배열 리터럴을 튜플로 유지: 이벤트/명령 목록

const를 붙이면 호출 시점의 배열 리터럴이 더 잘 보존됩니다.

function makeEmitter<const T extends readonly string[]>(events: T) {
  type Event = T[number];

  return {
    on(event: Event, handler: () => void) {
      // ...
    },
  };
}

const emitter = makeEmitter(["created", "deleted"]);

emitter.on("created", () => {});
// emitter.on("updated", () => {}); // 타입 에러

포인트는 두 가지입니다.

  • 타입 파라미터에 const를 붙임: const T
  • 매개변수 타입을 readonly string[] 계열로 받음: T extends readonly string[]

이렇게 하면 굳이 호출부에서 as const를 쓰지 않아도, T[number]가 안정적으로 유니온을 만들어줍니다.

2) 객체 리터럴의 값 리터럴 유지: 라우트/스키마 정의

function defineRoutes<const T extends Record<string, string>>(routes: T) {
  return routes;
}

const routes = defineRoutes({
  home: "/",
  user: "/users/:id",
});

type UserRoute = typeof routes.user;
// 기대대로 "/users/:id" 리터럴로 유지될 가능성이 커집니다.

여기서 얻는 실전 이점은 “문자열 기반 DSL”을 만들 때 큽니다.

  • 라우트 패턴 문자열
  • GraphQL operation name
  • 권한 스코프 문자열
  • 피처 플래그 키

3) 키 집합을 입력받아 안전한 pick 만들기

다음은 객체와 키 배열을 받아 pick을 구현하는 흔한 예시입니다. 키 배열이 string[]로 넓어지면 결과 타입이 무너집니다.

function pick<const TObj extends Record<string, unknown>, const TKeys extends readonly (keyof TObj)[]>(
  obj: TObj,
  keys: TKeys
): Pick<TObj, TKeys[number]> {
  const out: Partial<TObj> = {};
  for (const k of keys) {
    out[k] = obj[k];
  }
  return out as Pick<TObj, TKeys[number]>;
}

const user = { id: 1, name: "kim", admin: false };

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

// pick(user, ["id", "nope"]); // 타입 에러

여기서 keys가 리터럴 튜플로 유지되면 TKeys[number]가 정확한 유니온이 되고, 반환 타입이 깔끔하게 떨어집니다.

4) “옵션 객체”에서 플래그를 리터럴로 유지

옵션 객체의 불리언이 boolean으로 넓어지면 분기 기반 반환 타입을 만들기 어렵습니다.

type ParseOptions = {
  strict?: boolean;
  returnType?: "json" | "text";
};

function parseResponse<const T extends ParseOptions>(
  input: string,
  options?: T
): T extends { returnType: "json" } ? unknown : string {
  const rt = options?.returnType;
  if (rt === "json") return JSON.parse(input) as any;
  return input as any;
}

const a = parseResponse("{\"x\":1}", { returnType: "json" });
// a: unknown

const b = parseResponse("hello", { returnType: "text" });
// b: string

const T가 없으면 returnType이 쉽게 "json" | "text"로 넓어져 조건부 타입이 흐릿해질 수 있습니다.

const 타입 파라미터 vs as const vs satisfies

as const와의 관계

  • as const는 “값 자체를 읽기 전용 + 리터럴로 고정”하는 강력한 도구
  • 하지만 호출자에게 매번 요구하면 API 사용성이 떨어짐

const 타입 파라미터는 다음 상황에서 특히 좋습니다.

  • 라이브러리/유틸 함수가 호출부 리터럴을 최대한 보존해주길 원할 때
  • as const를 강제하지 않고도 대부분의 케이스에서 원하는 추론을 얻고 싶을 때

반대로 다음 상황에서는 여전히 as const가 필요할 수 있습니다.

  • 값이 변수에 담기면서 이미 넓어진 뒤 전달되는 경우
  • “반드시 readonly 튜플/readonly 객체”가 되어야만 하는 경우

예를 들어 아래처럼 중간 변수에 담으면 넓어질 수 있습니다.

const events = ["created", "deleted"]; // string[]로 넓어질 수 있음
const emitter = makeEmitter(events);

이때는 중간 변수 선언에서 as const 또는 명시 타입이 필요합니다.

const events = ["created", "deleted"] as const;
const emitter = makeEmitter(events);

satisfies와의 관계

  • satisfies는 “값이 어떤 타입 조건을 만족하는지 검증”하면서도 추론은 유지하는 데 강점
  • const 타입 파라미터는 “함수 호출에서의 추론을 더 좁게 유지”하는 데 강점

둘은 경쟁 관계가 아니라, 같이 쓰면 더 강력합니다.

type RouteTable = Record<string, `/${string}`>;

function defineRoutes<const T extends RouteTable>(routes: T) {
  return routes;
}

const routes = defineRoutes({
  home: "/",
  user: "/users/:id",
} satisfies RouteTable);

위 패턴은 다음을 동시에 달성합니다.

  • satisfies로 값이 RouteTable 규칙을 만족하는지 검증
  • const T로 각 값의 리터럴을 최대한 유지

실전 적용 체크리스트

1) “값 기반 API”인가를 먼저 판단

const 타입 파라미터는 특히 다음 케이스에서 투자 대비 효과가 큽니다.

  • 이벤트/액션 이름 목록에서 유니온 타입을 뽑아야 함
  • 라우트/쿼리 키/피처 플래그 키처럼 문자열 리터럴이 곧 계약인 경우
  • pick/omit/groupBy 같은 유틸에서 키 배열 리터럴을 보존해야 함

반대로 단순 데이터 처리 함수(예: 숫자 배열 합계)에는 굳이 필요 없습니다.

2) 타입 파라미터 제약을 readonly로 잡기

배열 인자를 받는다면 보통 아래처럼 시작하는 게 안전합니다.

  • const T extends readonly unknown[]
  • 혹은 const T extends readonly string[]

이렇게 해야 튜플 추론과 잘 맞습니다.

3) 반환 타입에서 T[number], keyof T를 적극 활용

const 타입 파라미터의 장점은 “리터럴이 남아있을 때” 폭발합니다.

  • 배열이면 T[number]로 유니온 생성
  • 객체면 keyof T로 키 유니온 생성
  • 매핑 타입으로 결과 스키마 생성

디버깅 팁: 추론이 넓어질 때 보는 포인트

  1. 값이 한 번이라도 변수에 저장되며 넓어지지 않았는지 확인
  • const x = ["a", "b"]; 는 상황에 따라 string[]
  • const x = ["a", "b"] as const;readonly ["a", "b"]
  1. 함수 매개변수 타입이 너무 넓게 선언되어 있지 않은지 확인

예를 들어 아래는 T를 좁히기 어렵게 만듭니다.

function bad<const T>(x: string[]): T {
  throw new Error("no");
}

입력 타입을 T와 연결해야 합니다.

function good<const T extends readonly string[]>(x: T): T {
  return x;
}
  1. 컨텍스트 타입이 리터럴을 덮어쓰고 있지 않은지 확인

객체 리터럴을 특정 타입으로 먼저 주석 처리하면 값이 넓어질 수 있습니다.

type Opt = { mode: string };
const opt: Opt = { mode: "dev" }; // mode가 string으로 확정

이때는 satisfies가 대안입니다.

type Opt = { mode: string };
const opt = { mode: "dev" } satisfies Opt; // mode는 "dev" 리터럴 유지 가능

마무리

TS 5.5+의 const 타입 파라미터는 “호출부 리터럴을 최대한 유지해 타입 계약을 강화하는” 도구입니다. 특히 배열/객체 리터럴을 입력받아 유니온 타입을 만들거나, 옵션 객체 기반으로 조건부 반환 타입을 제공하는 유틸에서 효과가 큽니다.

정리하면 다음처럼 가져가면 실전에서 실패 확률이 낮습니다.

  • 배열 입력: function f<const T extends readonly string[]>(x: T) 패턴으로 시작
  • 키 배열 유틸: const TKeys extends readonly (keyof TObj)[] 조합
  • 스키마 검증은 satisfies로, 호출 추론 고정은 const 타입 파라미터로 역할 분담

이미 satisfies를 적극 쓰고 있다면, 이제는 “함수 경계에서의 추론”을 const 타입 파라미터로 한 단계 더 단단하게 만들 수 있습니다.