Published on

TS 5.5+ const 타입 파라미터 추론 오류 해결

Authors

서로 다른 팀/패키지에서 TypeScript 5.5 이상으로 올린 뒤 갑자기 “리터럴이 유지될 줄 알았는데 string으로 넓혀졌다”, “as const를 했는데도 제네릭 추론이 엉킨다”, “오버로드가 갑자기 안 맞는다” 같은 이슈를 겪는 경우가 많습니다. 그 중심에 있는 기능 중 하나가 const 타입 파라미터(const type parameter) 입니다.

이 글은 TS 5.5+ 환경에서 const 타입 파라미터를 도입했거나, 라이브러리 업데이트로 간접적으로 영향을 받은 상황에서 추론 오류를 재현하고, 원인을 설명하고, 실무적인 해결책을 제시합니다.

참고: 캐시나 빌드 산출물 때문에 “수정했는데도 계속 이상하다”가 나오면, Next.js 쪽 캐시 문제도 같이 의심해볼 만합니다. 관련해서는 Next.js App Router RSC 캐시 꼬임 해결 가이드도 도움이 됩니다.

const 타입 파라미터란

TypeScript에서 제네릭은 보통 호출 시점의 인수로부터 타입을 추론합니다. 다만 배열/객체 리터럴을 인수로 넘기면, 기본적으로는 너무 좁은 타입으로 고정되지 않도록 넓혀(widening) 추론되는 일이 많습니다.

이를 제어하기 위해 기존에는 다음 같은 테크닉을 썼습니다.

  • 호출부에서 as const를 붙여 리터럴을 고정
  • TS 4.9의 satisfies로 형태만 검증하고 리터럴은 보존
  • 유틸 함수로 튜플/리터럴을 캡처

const 타입 파라미터는 여기서 한 발 더 나아가, 제네릭 타입 파라미터 자체를 const로 선언해 추론 시 리터럴/튜플 성질을 더 잘 보존하도록 돕습니다.

// TS 5.x
function defineRoute<const TPath extends string>(path: TPath) {
  return { path };
}

const r = defineRoute("/users");
// r.path: "/users" (리터럴 유지)

하지만 이 기능을 “만능 리터럴 고정 장치”처럼 사용하면, 오히려 추론 경로가 바뀌면서 기존에 통과하던 코드가 실패하거나, 반대로 타입이 지나치게 좁아져서 에러가 발생할 수 있습니다.

문제 1: 배열/튜플 인수에서 기대와 다른 추론

재현: 유니온으로 넓혀져야 하는데 튜플로 고정됨

예를 들어, 어떤 API가 문자열 배열을 받아서 그 중 하나를 선택하게 만들고 싶다고 합시다.

function pickOne<T extends string>(items: T[]) {
  return items[Math.floor(Math.random() * items.length)] as T;
}

const v1 = pickOne(["red", "blue"]);
// 보통 v1: "red" | "blue" 를 기대

여기에 const 타입 파라미터를 섣불리 붙이면 상황이 달라질 수 있습니다.

function pickOne2<const T extends readonly string[]>(items: T) {
  return items[Math.floor(Math.random() * items.length)];
}

const v2 = pickOne2(["red", "blue"]);
// v2: "red" | "blue" 로 보일 수도 있지만,
// 다른 조합의 코드에서는 "red" 같은 단일 리터럴로 과도하게 좁혀지는 케이스가 생길 수 있음

왜 문제가 되나

const T extends readonly string[]는 호출부의 배열 리터럴을 튜플로 캡처하는 경향이 있습니다. 튜플로 캡처되면 T[number]가 유니온이 되긴 하지만, 이후 로직(오버로드 선택, 조건부 타입 분기, 인덱싱 방식)에서 추론이 달라져서 결과가 바뀔 수 있습니다.

해결 패턴: “튜플 캡처”와 “유니온 추론”을 분리

튜플을 유지하고 싶을 때와, 단순히 요소 유니온만 필요할 때는 타입 파라미터를 분리하는 편이 안전합니다.

function pickOneSafe<const T extends readonly string[]>(items: T): T[number] {
  return items[Math.floor(Math.random() * items.length)] as T[number];
}

const v = pickOneSafe(["red", "blue"]);
// v: "red" | "blue"

또는 아예 입력을 배열로 받고 요소 타입만 추론하도록 설계합니다.

function pickOneUnion<T extends string>(items: readonly T[]): T {
  return items[Math.floor(Math.random() * items.length)] as T;
}

핵심은 const를 붙이는 지점이 API의 의도와 맞는지입니다. “입력 리터럴을 구조적으로 보존해야 한다”면 const가 유리하지만, “요소 유니온만 필요하다”면 오히려 기존 방식이 더 예측 가능합니다.

문제 2: 오버로드/조건부 타입에서 추론 경로가 바뀌는 오류

const 타입 파라미터는 추론을 더 공격적으로 좁힐 수 있고, 그 결과 오버로드 선택이 달라지거나, 조건부 타입이 다른 가지로 타면서 컴파일 에러가 발생할 수 있습니다.

재현: 오버로드가 갑자기 안 맞음

type Json = string | number | boolean | null | Json[] | { [k: string]: Json };

function send(payload: string): void;
function send(payload: Json): void;
function send(payload: unknown) {
  // ...
}

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

send(wrap("hello"));

여기서 wrapconst 타입 파라미터로 인해 "hello"를 너무 좁게 유지하는 것은 문제 없어 보이지만, 실제 코드에서는 wrap이 추가적인 타입 변환(예: T extends ... ? ... : ...)을 포함하고 있을 때 오버로드가 엇갈리는 일이 생깁니다.

해결 패턴: 오버로드 경계에서는 타입을 “의도적으로” 넓히기

오버로드가 기대하는 형태(예: string)로 통과시키려면, 경계에서 명시적으로 넓혀주는 것이 안전합니다.

function asString(x: string): string {
  return x;
}

send(asString(wrap("hello")));

또는 wrap 자체를 두 레이어로 분리합니다.

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

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

send(wrapWide(wrapConst("hello")));

이 방식은 “내부에서는 최대한 리터럴을 유지”하고, “외부 API 경계에서는 안정적으로 넓힌 타입을 제공”하는 전략입니다.

문제 3: 객체 리터럴에서 as constconst 제네릭이 충돌하는 느낌

객체 리터럴은 속성 값이 리터럴로 유지되길 바라지만, 동시에 너무 좁아져서 재사용이 어려워지는 문제가 있습니다.

재현: 지나치게 좁은 타입으로 고정되어 확장이 불가

function defineConfig<const T extends { mode: string }>(cfg: T) {
  return cfg;
}

const cfg = defineConfig({ mode: "dev" });
// cfg.mode: "dev" 로 고정

function run(mode: "dev" | "prod") {}
run(cfg.mode); // OK

function run2(mode: string) {}
run2(cfg.mode); // OK지만, 다른 곳에서 역으로 문제가 될 수 있음

문제는 보통 여기서 끝나지 않습니다. cfg를 기반으로 다른 설정을 합치거나, 부분 업데이트를 하려는 순간 타입이 너무 좁아져서 충돌합니다.

해결 패턴 A: satisfies로 “형태만” 검증하고 리터럴은 호출부에서 선택

type Config = {
  mode: "dev" | "prod";
  retries: number;
};

const cfg2 = {
  mode: "dev",
  retries: 3,
} satisfies Config;
// cfg2.mode는 "dev" 리터럴 유지, 동시에 Config 형태 검증

해결 패턴 B: API가 반환하는 타입을 의도적으로 일반화

반환 타입을 입력 T 그대로 돌려주지 말고, 소비자가 쓰기 좋은 “공식 타입”으로 매핑합니다.

type NormalizedConfig = {
  mode: "dev" | "prod";
  retries: number;
};

function defineConfig2<const T extends NormalizedConfig>(cfg: T): NormalizedConfig {
  return cfg;
}

const cfg3 = defineConfig2({ mode: "dev", retries: 3 });
// cfg3는 NormalizedConfig로 정규화되어 과도한 리터럴 고정이 줄어듦

이 패턴은 라이브러리 API(설정 로더, 라우팅 정의, 스키마 정의 등)에서 특히 효과적입니다.

문제 4: const 타입 파라미터를 “아무 데나” 붙였을 때 생기는 대표 에러들

실무에서 자주 보는 증상은 다음과 같습니다.

  • Type 'string' is not assignable to type '"foo"'처럼 너무 좁아진 리터럴 때문에 대입 실패
  • No overload matches this call처럼 오버로드 분기 변화
  • Type instantiation is excessively deep and possibly infinite처럼 조건부 타입과 결합해 추론 복잡도 폭발

특히 마지막 케이스는 const 자체가 원인이라기보다, const로 인해 리터럴/튜플이 유지되면서 조건부 타입이 더 많은 분기를 타게 되어 발생하는 경우가 많습니다.

해결 체크리스트

  1. 정말 입력 리터럴을 구조적으로 보존해야 하는가
    • 예: 라우트 문자열, 이벤트 이름 목록, 고정 키 배열 등은 보존 가치가 큼
    • 예: 단순 데이터 배열 처리 함수는 보존이 오히려 독
  2. 반환 타입이 T 그대로인지 확인
    • 그대로 반환하면 타입이 지나치게 좁아져 전파됨
    • “정규화 타입”을 반환하는 설계 고려
  3. 오버로드/외부 라이브러리 경계에서 넓히기
    • string/number 같은 원시 타입 경계는 명시적으로 캐스팅 또는 헬퍼 함수
  4. 조건부 타입이 있다면 분기 수를 줄이기
    • 입력을 튜플로 캡처하면 T[0], T[1] 같은 분기가 늘어날 수 있음

실전 예제: 라우터 정의에서 const 제네릭을 안전하게 쓰는 법

라우팅/핸들러 매핑은 const 타입 파라미터가 빛을 발하는 영역이지만, 반환 타입을 잘못 설계하면 추론 오류가 커집니다.

type Handler = (req: { path: string }) => Promise<Response>;

type RouteDef = {
  path: string;
  method: "GET" | "POST";
  handler: Handler;
};

function defineRoute<const T extends RouteDef>(def: T) {
  // 여기서 T를 그대로 반환하면 리터럴이 과도하게 전파될 수 있음
  return def;
}

const route = defineRoute({
  path: "/users",
  method: "GET",
  handler: async () => new Response("ok"),
});
// route.path: "/users", route.method: "GET"

여기까지는 좋아 보이지만, 여러 라우트를 합치는 순간 문제가 생길 수 있습니다.

const routes = [route];
// routes의 타입이 너무 구체적이면, 다른 route 추가 시 충돌 가능

개선: 반환을 정규화하고, 컬렉션은 넓힌 타입으로 받기

function defineRouteNormalized<const T extends RouteDef>(def: T): RouteDef {
  return def;
}

const r1 = defineRouteNormalized({
  path: "/users",
  method: "GET",
  handler: async () => new Response("ok"),
});

const r2 = defineRouteNormalized({
  path: "/users",
  method: "POST",
  handler: async () => new Response("created"),
});

const routes2: RouteDef[] = [r1, r2];
  • 정의 시점에는 const로 실수(오타, 잘못된 리터럴)를 줄이고
  • 저장/조합 시점에는 RouteDef로 넓혀서 확장성을 확보합니다.

TS 5.5+에서 “추론 오류”가 발생했을 때 디버깅 루틴

타입 추론 문제는 로그가 없어서 막막한데, 일정한 순서로 보면 빨리 좁혀집니다.

  1. 문제가 되는 호출부를 최소 재현 코드로 축소
  2. 제네릭 선언에서 const를 제거해보고 차이를 확인
  3. 입력 인수에 as const를 붙였을 때와 비교
  4. 반환 타입을 T에서 “정규화 타입”으로 바꿔보기
  5. 타입 표면을 확인하기 위해 중간 타입 별칭을 둠
type Debug<T> = T;

function f<const T extends readonly string[]>(x: T) {
  type X = Debug<T>;       // 튜플로 캡처됐는지 확인
  type U = Debug<T[number]>; // 요소 유니온 확인
  return x;
}

이렇게 T 자체가 튜플로 잡히는지, T[number]가 원하는 유니온인지부터 확인하면 원인이 명확해집니다.

마이그레이션 팁: 라이브러리/팀 코드에서 안전하게 도입하기

  • 공용 유틸 함수에 const 타입 파라미터를 추가할 때는, 반환 타입이 T인지부터 점검하세요. T 반환은 리터럴 전파가 강해서 하위 호환성을 깨기 쉽습니다.
  • “정의 함수(define 계열)”에는 const가 유리하지만, “처리 함수(map/filter/reduce 계열)”에는 불리한 경우가 많습니다.
  • 모노레포에서 TS 버전이 섞여 있으면, 타입 선언만 TS 5.5 문법을 쓰는 순간 하위 패키지 빌드가 깨질 수 있습니다. 패키지별 typescript 버전과 types 배포 전략을 확인하세요.

정리

TS 5.5+의 const 타입 파라미터는 리터럴/튜플 추론을 강하게 만들어 타입 안정성을 높여주지만, 그만큼 추론 경로를 바꿔서 기존 코드에 “추론 오류”처럼 보이는 변화를 만들 수 있습니다.

  • 입력 리터럴을 보존해야 하는 API에만 선택적으로 적용
  • 반환 타입은 필요하면 정규화해서 과도한 리터럴 전파를 차단
  • 오버로드/외부 경계에서는 의도적으로 타입을 넓히기
  • 조건부 타입과 결합 시 분기 폭발을 경계

이 원칙으로 정리하면, const 타입 파라미터는 디버깅 포인트가 아니라 강력한 도구가 됩니다.