Published on

TS 5.5 const type params로 타입추론 고치기

Authors

서버·프론트 공용 유틸이나 SDK를 만들다 보면, 분명히 리터럴로 넘겼는데 타입이 갑자기 string[] 혹은 Record<string, string> 같은 넓은 타입으로 퍼져서(와이드닝) 자동완성·오타 방지·분기 추론이 무너지는 순간이 있습니다.

TypeScript 5.5에서 도입된 const type parameters는 이런 문제를 “호출 시점의 리터럴 정보를 더 잘 보존”하도록 돕는 기능입니다. 기존의 as const 남발이나 오버로드 폭증을 줄이면서, 라이브러리/유틸 API의 타입 품질을 한 단계 끌어올릴 수 있습니다.

이 글에서는 다음을 목표로 합니다.

  • 왜 제네릭 함수에서 리터럴 추론이 깨지는지 이해
  • TS 5.5 const 타입 파라미터로 어떻게 고치는지
  • 언제 쓰면 좋은지, 언제는 as const/satisfies가 더 나은지
  • 실무에서 바로 가져다 쓸 수 있는 패턴과 주의점

문제: 제네릭에서 리터럴이 자꾸 와이드닝된다

가장 흔한 케이스는 “키 목록을 받아서 객체를 만든다” 같은 유틸입니다.

// TS 5.4 이하에서도 흔히 쓰던 형태
function pick<T, K extends readonly (keyof T)[]>(obj: T, keys: K) {
  const out = {} as Pick<T, K[number]>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

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

const keys = ["id", "role"]; // 여기서 keys는 string[] 로 와이드닝되기 쉬움
const result = pick(user, keys);
// 기대: { id: number; role: string }
// 현실: Pick<{...}, string> 비슷하게 깨지거나, K 추론이 부정확해지는 경우가 생김

원인은 간단합니다.

  • const keys = ["id", "role"]는 기본적으로 string[]로 추론될 수 있습니다(컨텍스트/버전/옵션/사용처에 따라 다르게 보일 수 있지만, “리터럴 튜플”로 고정되지 않는 상황이 많습니다).
  • 제네릭 K extends readonly (keyof T)[]keysstring[]로 들어오면 K[number]string이 되어버려, Pick이 사실상 무력화됩니다.

물론 해결책은 있었습니다.

const keys = ["id", "role"] as const;
const result = pick(user, keys);

하지만 매번 as const를 강제하는 API는 호출 경험이 나쁘고, 팀 코드베이스에 단언이 퍼지기 쉽습니다.

TS 5.5 해결책: const type parameters

TS 5.5의 핵심은 제네릭 타입 파라미터에 const를 붙여 “추론 시 리터럴을 더 적극적으로 유지”하도록 만드는 것입니다.

다음처럼 바꿉니다.

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

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

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

포인트는 호출부가 더 이상 as const를 강요받지 않는다는 점입니다. keys 인자 자리에 리터럴 배열을 직접 넣으면, Kreadonly ["id", "role"] 같은 형태로 추론될 가능성이 커집니다.

const type params가 정확히 “뭘” 고치나

  • 제네릭 추론에서 리터럴(문자열 리터럴, 숫자 리터럴, 튜플 형태)을 가능한 한 유지
  • 결과적으로 K[number]"id" | "role" 같은 유니온으로 남아, Pick/분기/매핑 타입이 제대로 동작

이 기능은 특히 “입력값의 구체성이 곧 출력 타입의 구체성”으로 이어지는 API에서 체감이 큽니다.

패턴 1: 라우트/이벤트 정의에서 리터럴 보존

이벤트 이름 목록을 받아 핸들러 맵을 만드는 패턴을 생각해봅시다.

type HandlerMap<E extends string> = {
  [K in E]: (payload: { type: K }) => void;
};

function createHandlers<const E extends readonly string[]>(events: E) {
  type Event = E[number];
  const handlers = {} as HandlerMap<Event>;
  return handlers;
}

const handlers = createHandlers(["LOGIN", "LOGOUT"]);
// handlers는 LOGIN, LOGOUT 키만 허용

handlers.LOGIN = (p) => {
  // p.type은 "LOGIN"
};

// handlers.SIGNUP = ... // 오류: 존재하지 않는 키

const 타입 파라미터가 없다면 eventsstring[]로 와이드닝되어 Eventstring이 되고, 결국 HandlerMap<string>으로 무한히 열려버립니다.

패턴 2: 옵션 객체의 “문자열 리터럴” 유지

옵션 객체를 받아 반환 타입을 분기하는 함수에서 특히 유용합니다.

type Mode = "compact" | "full";

type Result<M extends Mode> = M extends "compact"
  ? { kind: "compact"; items: number[] }
  : { kind: "full"; items: number[]; meta: { total: number } };

function fetchItems<const O extends { mode: Mode }>(opt: O): Result<O["mode"]> {
  // 구현은 생략
  return null as any;
}

const a = fetchItems({ mode: "compact" });
// a.kind: "compact"

const b = fetchItems({ mode: "full" });
// b.meta.total 접근 가능

여기서 const가 없다면 opt.mode가 종종 Mode로 넓어져서 반환 타입이 Result<Mode>가 되어 분기 정보가 흐려질 수 있습니다.

패턴 3: “튜플 기반” API에서 오버로드 줄이기

pipe, compose, match 같은 함수는 튜플의 길이/원소 타입이 매우 중요합니다.

간단한 tupleToObject 예시로 보겠습니다.

function tupleToObject<const T extends readonly string[]>(...keys: T) {
  type Key = T[number];
  return (value: number) => {
    const out = {} as Record<Key, number>;
    for (const k of keys) out[k] = value;
    return out;
  };
}

const make = tupleToObject("x", "y", "z");
const o = make(10);
// o: Record<"x" | "y" | "z", number>

이런 류의 API는 기존에는 as const를 강제하거나, 오버로드를 여러 개 두어야 사용성이 좋았습니다. const type params는 이 부담을 줄입니다.

언제 as const가 여전히 필요한가

const type parameters가 만능은 아닙니다. 특히 “변수에 담긴 값”이 이미 와이드닝된 뒤라면, 제네릭 쪽에서 되돌리기 어렵습니다.

const keys = ["id", "role"]; // 이미 string[] 로 추론되면
pick({ id: 1, role: "admin" }, keys); // 여기서 const type params가 있어도 한계

이 경우는 호출부에서 아래처럼 해야 합니다.

const keys = ["id", "role"] as const;

즉, const type params는 “호출 시점에 리터럴을 직접 전달”하거나, “아직 리터럴 정보가 살아있는 값”에 특히 효과적입니다.

satisfies와의 조합: 안전하게 리터럴 유지하기

as const는 강력하지만 단언이므로 남용하면 위험합니다. 이때 satisfies가 좋은 균형을 제공합니다.

const keys = ["id", "role"] as const satisfies readonly string[];

혹은 객체 설정을 검증하면서 리터럴을 유지할 때:

type Config = { mode: "compact" | "full"; retry: number };

const cfg = {
  mode: "compact",
  retry: 3,
} satisfies Config;

fetchItems(cfg);
  • satisfies는 구조가 요구사항을 만족하는지 검사
  • 동시에 값의 구체적인 리터럴 타입을 가능한 유지

const type params는 “함수 경계”에서, satisfies는 “값 선언”에서 타입 품질을 올리는 도구로 보면 깔끔합니다.

실무 적용 가이드: 어디에 붙이면 효과가 큰가

다음 체크리스트에 해당하면 const 타입 파라미터 도입을 추천합니다.

1) 입력이 리터럴이면 출력도 더 구체적이어야 하는가

  • 키 목록으로 Pick/Omit을 만드는 유틸
  • 이벤트 이름 배열로 핸들러 맵을 만드는 팩토리
  • 옵션 값에 따라 반환 타입이 달라지는 함수

2) 호출부에 as const가 반복되는가

라이브러리 소비자가 매번 as const를 써야 한다면, API가 리터럴 추론을 충분히 살리지 못하는 신호입니다.

3) 오버로드가 “타입 추론 보정” 때문에 늘어났는가

오버로드는 유지보수 비용이 큽니다. const type params로 대체 가능한지 먼저 확인해보는 편이 좋습니다.

주의점: 타입이 너무 구체적이어서 생기는 문제

리터럴을 보존하면 좋은 점이 많지만, 때로는 “너무 구체적”이라 재사용성이 떨어질 수 있습니다.

예를 들어 다음 함수가 있다고 합시다.

function acceptModes<const M extends readonly ("compact" | "full")[]>(modes: M) {
  return modes;
}

const m = acceptModes(["compact", "full"]);

mreadonly ["compact", "full"]로 고정되면, 이후에 일반적인 ("compact" | "full")[]로 다루고 싶을 때 불편할 수 있습니다. 이런 경우 반환 타입을 의도적으로 넓히거나, 외부로 노출되는 타입은 적절히 추상화하는 게 좋습니다.

function acceptModes<const M extends readonly ("compact" | "full")[]>(modes: M): ("compact" | "full")[] {
  return [...modes];
}

즉, “내부 추론은 구체적으로, 외부 계약은 적절히 일반화”가 API 설계의 핵심입니다.

마이그레이션 팁: 기존 코드에 점진적으로 적용하기

  • 유틸 함수 중 K[number], T[number] 같은 인덱스드 액세스가 핵심인 곳부터 적용
  • 호출부에 as const가 자주 보이는 함수 시그니처를 우선 개선
  • 리턴 타입이 지나치게 구체적으로 굳어 테스트/호출이 불편해지면, 반환 타입을 한 단계 넓히는 전략 병행

그리고 타입 개선 작업은 “한 번에 크게” 하기보다, 작은 단위로 적용하고 컴파일/IDE 경험이 실제로 좋아지는지 확인하는 편이 안전합니다. 빌드/런타임 이슈를 다루는 글이지만, 문제를 쪼개서 진단하는 접근은 성능 트러블슈팅에서도 똑같이 유효합니다. 예를 들어 프론트에서 병목을 쪼개 잡는 방식은 Chrome 렌더링 느림 - Long Task 잡는 법 같은 글의 접근과도 통합니다.

정리

TS 5.5의 const type parameters는 제네릭 API에서 리터럴 추론이 무너지는 대표 케이스를 깔끔하게 해결합니다.

  • 호출부 as const 의존도를 줄이고
  • 키/이벤트/옵션 기반 API의 타입 정확도를 올리며
  • 오버로드를 줄여 유지보수성을 개선할 수 있습니다.

추천하는 적용 순서는 다음입니다.

  1. Pick/Omit/매핑 타입을 만드는 유틸부터 const 타입 파라미터 적용
  2. 옵션에 따른 반환 타입 분기 함수에 적용
  3. 필요하면 satisfies로 값 선언부의 안정성까지 보강

이 조합만으로도 “타입은 있는데 도움이 안 되는” 상태에서 “타입이 실제로 개발 속도를 올려주는” 상태로 체감이 크게 달라질 것입니다.