Published on

TS 5.5 const 타입 파라미터로 제네릭 추론 고치기

Authors

서드파티 SDK나 사내 유틸을 만들다 보면 “런타임 값은 분명히 고정인데, 타입은 왜 이렇게 넓어지지?” 같은 순간이 자주 옵니다. 특히 배열/튜플을 입력으로 받아 그 요소를 기반으로 반환 타입을 구성하는 API에서 제네릭 추론이 string[] 같은 형태로 퍼져버리면, 이후의 체이닝과 자동완성 품질이 급격히 떨어집니다.

TypeScript 5.5는 이런 문제를 자주 일으키던 패턴을 더 깔끔하게 해결할 수 있도록 const 타입 파라미터를 도입했습니다. 핵심은 “호출 시 전달한 리터럴/튜플 구조를 제네릭 추론 단계에서 더 강하게 보존”하는 것입니다.

이 글에서는 TS 5.5의 const 타입 파라미터가 무엇을 해결하는지, 기존의 as const/오버로드/헬퍼 함수와 비교하면 어떤 차이가 있는지, 그리고 실무에서 바로 적용 가능한 패턴을 코드로 정리합니다. 추가로 타입 시스템 관점에서 비슷한 맥락의 문제 해결 글로 TS 5.7 - satisfies로 타입 좁히기 실패 해결도 함께 보면 좋습니다.

제네릭 추론이 “넓어지는” 전형적인 문제

예를 들어, 키 목록을 받아 해당 키만 pick 해서 반환하는 함수를 만든다고 해보겠습니다.

type User = {
  id: string;
  name: string;
  age: number;
};

function pick<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: User = { id: "u1", name: "kim", age: 20 };

const r1 = pick(user, ["id", "name"]);
// 기대: { id: string; name: string }
// 실제(자주 발생): K가 string으로 넓어지거나, keys가 ("id"|"name")[]로 잘 잡히지 않으면 결과도 덜 정밀해짐

여기서 관건은 두 번째 인자인 keys가 “튜플 리터럴”로 남아 있어야 K가 정확히 "id" | "name"으로 추론된다는 점입니다. 하지만 호출부에서 배열 리터럴은 상황에 따라 쉽게 string[]로 넓어질 수 있고, 그러면 K도 넓어져 결과 타입이 무의미해집니다.

실무에서는 이런 케이스가 더 자주 등장합니다.

  • 라우트 정의: createRoutes([ ... ])
  • 이벤트 이름 목록: on(["created", "deleted"], handler)
  • SQL select 컬럼 목록: select(["id", "name"])
  • i18n 키 목록: t(["home.title", "home.desc"])

TS 5.5 const 타입 파라미터란

TS 5.5의 const 타입 파라미터는 제네릭 타입 파라미터 선언에 const를 붙여, 추론 시 리터럴/튜플 성질을 더 강하게 보존하도록 하는 기능입니다.

형태는 다음과 같습니다.

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

이때 T는 단순히 “readonly가 된다”가 아니라, “추론이 리터럴 중심으로 고정되는 방향”으로 작동합니다. 특히 배열 리터럴을 넣었을 때 Tstring[]로 넓어지기보다 readonly ["id", "name"] 같은 튜플로 잡히는 것이 중요한 포인트입니다.

pick 예제를 const 타입 파라미터로 고치기

아까의 pick을 TS 5.5 방식으로 개선해보겠습니다.

type User = {
  id: string;
  name: string;
  age: number;
};

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: User = { id: "u1", name: "kim", age: 20 };

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

여기서 바뀐 점은 두 가지입니다.

  • Kconst 타입 파라미터로 선언해서, ["id", "name"]가 튜플로 더 잘 유지되도록 함
  • keys의 타입을 K[]가 아니라 K 그 자체로 받아서(즉, “튜플/readonly 배열”을 그대로 받음) K[number]로 유니온을 뽑아냄

이 패턴은 “배열을 입력받아 그 요소 유니온을 기반으로 반환 타입을 구성”하는 함수에 거의 그대로 적용됩니다.

readonly가 함께 나오나

const 타입 파라미터는 튜플/리터럴 보존과 매우 친하고, 이때 결과 타입은 대개 readonly 성격을 띱니다. 그래서 제약을 readonly (keyof T)[]로 거는 것이 자연스럽습니다.

호출부에서 굳이 as const를 쓰지 않아도 되는 게 장점이지만, 타입 제약 자체는 “readonly 배열도 받는다”로 열어두는 편이 좋습니다.

기존 해결책과 비교: as const 남발 vs API 설계

TS 5.5 이전에는 보통 다음 중 하나로 해결했습니다.

1) 호출부에서 as const 강제

const r = pick(user, ["id", "name"] as const);

단점은 호출부가 지저분해지고, 라이브러리 사용자가 매번 타입 트릭을 기억해야 한다는 점입니다.

2) 헬퍼 tuple 함수로 감싸기

const tuple = <T extends readonly unknown[]>(...t: T) => t;

const r = pick(user, tuple("id", "name"));

호출은 깔끔하지만, “왜 이런 헬퍼가 있어야 하지?”라는 학습 비용이 생깁니다.

3) 오버로드로 경우의 수 나열

키가 1개, 2개, 3개일 때 오버로드를 만들면 어느 정도 해결되지만, 유지보수 비용이 매우 큽니다.

TS 5.5의 const 타입 파라미터는 이 문제를 “API 설계로 해결”하게 해줍니다. 즉, 라이브러리 작성자가 한 번 고치면 사용자 경험이 전반적으로 좋아집니다.

실전 패턴 1: createQuery에서 select 컬럼 추론

ORM/쿼리 빌더 스타일 API에서 select(["id", "name"]) 같은 입력은 흔합니다.

type Row = {
  id: number;
  name: string;
  email: string;
};

function select<const Cols extends readonly (keyof Row)[]>(cols: Cols) {
  type Selected = Pick<Row, Cols[number]>;
  return {
    cols,
    map(row: Row): Selected {
      const out = {} as Selected;
      for (const c of cols) out[c] = row[c];
      return out;
    },
  };
}

const q = select(["id", "email"]);
// q.map(...)의 반환이 { id: number; email: string }로 유지됨

포인트는 Cols[number]로 유니온을 뽑는 방식입니다. 이때 Cols가 튜플로 잡혀야 "id" | "email"이 되는데, const 타입 파라미터가 그 역할을 안정적으로 해줍니다.

실전 패턴 2: 라우트 정의에서 path 파라미터 추론 유지

라우트 테이블을 배열로 정의하고, 그 배열을 기반으로 타입 안전한 navigate를 만들고 싶은 경우가 많습니다.

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

function defineRoutes<const R extends readonly Route[]>(routes: R) {
  type Names = R[number]["name"];

  function navigate(name: Names) {
    return name;
  }

  return { routes, navigate };
}

const appRoutes = defineRoutes([
  { name: "home", path: "/" },
  { name: "user", path: "/users/:id" },
]);

appRoutes.navigate("home");
// appRoutes.navigate("settings"); // 컴파일 에러

이런 구조는 Next.js/React Router/사내 라우터 등 어디든 적용됩니다. 특히 라우트 목록이 “그냥 배열 리터럴”일 때 추론이 무너지기 쉬운데, const 타입 파라미터로 라우트의 리터럴 성질을 최대한 보존할 수 있습니다.

Next.js를 쓰는 팀이라면 런타임 이슈와 타입 이슈가 함께 얽히는 경우가 많은데, 렌더 단계 문제는 Next.js Hydration mismatch 원인 9가지와 해결법도 참고할 만합니다.

주의할 점: “항상 더 좁게”가 정답은 아니다

const 타입 파라미터는 강력하지만, 다음 상황에서는 의도치 않게 너무 좁아져 불편할 수 있습니다.

  • 입력이 사용자 입력/외부 데이터라서 원래 넓어야 하는 경우
  • 리터럴이 지나치게 고정되어, 후속 연산에서 재사용성이 떨어지는 경우

예를 들어, function f<const T extends string>(x: T): T는 호출부에서 "abc"를 넣으면 반환도 "abc"로 고정됩니다. 어떤 API는 그게 장점이지만, 어떤 API는 오히려 string을 원할 수 있습니다.

이럴 땐 다음 중 하나를 선택합니다.

  • const를 빼고 기존 추론을 유지
  • 반환 타입을 의도적으로 widen 하도록 설계(예: string으로 반환)
  • 입력 타입을 T | string 같은 형태로 조정

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

사내 유틸/라이브러리에 적용할 때는 아래 순서가 안전합니다.

  1. 배열/튜플 입력을 받는 제네릭 함수 중, 반환 타입이 T[number] 또는 K[number]에 의존하는 곳을 찾기
  2. 해당 제네릭 파라미터를 const로 바꾸고, 파라미터 타입을 K[]가 아니라 K로 받도록 수정
  3. 제약을 readonly ...[]로 바꿔 호출부의 호환성을 확보
  4. 호출부의 as const를 점진적으로 제거(테스트/타입체크 통과 확인)

예시 체크리스트는 다음과 같습니다.

// 변경 전
function f<T, K extends string>(keys: K[]) {
  type U = K;
  return keys;
}

// 변경 후
function f<const K extends readonly string[]>(keys: K) {
  type U = K[number];
  return keys;
}

정리

TS 5.5의 const 타입 파라미터는 “리터럴/튜플 기반 API에서 제네릭 추론이 넓어져 타입 정보가 사라지는 문제”를 라이브러리 설계 단계에서 해결하게 해줍니다.

  • 호출부 as const 의존도를 줄이고
  • K[number] 패턴의 정확도를 높이며
  • 라우트/쿼리/이벤트/스키마 같은 선언적 배열 정의에서 DX를 크게 개선합니다.

타입이 기대보다 넓어져 자동완성이 망가지거나, 반환 타입이 Record<string, ...> 같은 형태로 퉁쳐지는 순간이 있다면, 그 함수의 제네릭 파라미터에 const를 붙일 수 있는지부터 점검해보는 것이 가장 빠른 개선책입니다.