Published on

TS 5.5 const 타입 파라미터로 추론 강화

Authors

서버/프론트 공통으로 TypeScript를 쓰다 보면, 런타임 값은 변하지 않는데 타입 추론이 너무 넓어져서(예: string[]로 뭉개짐) 안전성이 떨어지는 순간이 자주 나옵니다. 대표적으로 라우트 정의, 이벤트 이름 목록, 권한 스코프 목록, 쿼리 키, 설정 스키마 같은 “리터럴 목록”을 함수 인자로 넘길 때입니다.

TypeScript는 기본적으로 리터럴을 가능한 한 구체적으로 추론하려고 하지만, 함수 인자 위치에서는 종종 추론이 넓어집니다. 그동안은 이를 막기 위해 as const를 덕지덕지 붙이거나, 오버로드/헬퍼 타입으로 우회하는 패턴이 많았습니다.

TypeScript 5.5에서 도입된 const 타입 파라미터는 이런 상황에서 “이 타입 파라미터는 리터럴을 최대한 유지해서 추론해줘”라는 의도를 함수 시그니처에 직접 표현할 수 있게 해줍니다. 결과적으로 호출부의 as const 의존도를 줄이고, 라이브러리/내부 유틸 API를 더 깔끔하게 설계할 수 있습니다.

관련해서 TS 5.5의 다른 설정 이슈가 궁금하다면 TS 5.5 useDefineForClassFieldsthis undefined 해결도 함께 보면 좋습니다.

const 타입 파라미터란

기존 제네릭은 보통 다음처럼 작성합니다.

function pickKeys<T extends object>(obj: T, keys: (keyof T)[]) {
  // ...
}

하지만 호출부에서 keys에 배열 리터럴을 넘기면, 상황에 따라 ("a" | "b")[]로 잘 유지되기도 하고, string[]로 넓어지기도 합니다. 특히 중간 변수로 빼거나, 다른 제네릭과 얽히면 추론이 쉽게 넓어집니다.

TS 5.5의 const 타입 파라미터는 타입 파라미터 선언에 const를 붙여서, 해당 타입 파라미터가 리터럴/튜플/readonly 구조를 최대한 유지하도록 유도합니다.

function defineRoutes<const T extends readonly string[]>(routes: T) {
  return routes;
}

핵심은 호출부에서 별도의 as const 없이도:

  • 배열 리터럴이 string[]가 아니라 튜플처럼 유지되고
  • 각 원소가 string이 아니라 리터럴 유니온으로 유지되며
  • readonly 성질도 더 잘 보존된다는 점입니다.

왜 필요한가: as const의 비용

as const는 강력하지만 비용이 있습니다.

  • 호출부가 장황해짐
  • “이 값은 정말로 상수로 취급해도 되는가”를 호출자가 항상 알아야 함
  • 라이브러리 API의 사용성 저하(특히 팀 내 공용 유틸)
  • 중간 변수로 빼는 순간 다시 widening(타입 넓어짐) 문제가 재발하기 쉬움

const 타입 파라미터는 이 책임을 API 제공자(함수 시그니처) 쪽으로 옮깁니다. 즉 “이 함수는 리터럴 정보를 기반으로 타입을 만들겠다”는 의도를 선언적으로 드러냅니다.

예제 1: 이벤트 이름 목록에서 안전한 emit/on 만들기

가장 흔한 케이스가 이벤트 시스템입니다.

기존 방식(호출부 as const 필요)

function createEmitter<T extends readonly string[]>(events: T) {
  type EventName = T[number];

  return {
    on(name: EventName, handler: () => void) {
      // ...
    },
    emit(name: EventName) {
      // ...
    },
  };
}

const emitter = createEmitter(["open", "close"] as const);
emitter.emit("open");
// emitter.emit("oops"); // 오류가 나길 기대

호출부가 as const를 빼먹으면 eventsstring[]로 추론되어 EventNamestring이 되어버릴 수 있습니다.

TS 5.5 방식(const 타입 파라미터)

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

  return {
    on(name: EventName, handler: () => void) {
      // ...
    },
    emit(name: EventName) {
      // ...
    },
  };
}

const emitter = createEmitter(["open", "close"]);
emitter.emit("open");
// emitter.emit("oops"); // 타입 오류

호출부가 훨씬 자연스러워지고, 실수 가능성이 줄어듭니다.

예제 2: 라우트/권한 스코프 같은 DSL 정의

“문자열 리터럴 목록”을 기반으로 타입을 만드는 DSL은 const 타입 파라미터의 대표적인 수혜자입니다.

type ScopeMap<T extends readonly string[]> = {
  [K in T[number]]: true;
};

function defineScopes<const T extends readonly string[]>(scopes: T) {
  const map = Object.fromEntries(scopes.map((s) => [s, true])) as ScopeMap<T>;
  return map;
}

const scopes = defineScopes(["read:user", "write:user", "admin"]);

// scopes["read:user"]: true
// scopes["nope"]: 타입 오류

이 패턴은 백엔드 권한 체크나 프론트 기능 플래그 같은 곳에서 특히 유용합니다.

예제 3: pick/omit 류 유틸에서 키 배열 추론 보강

객체에서 특정 키만 고르는 유틸을 직접 만들 때도 widening이 자주 발생합니다.

type PickByKeys<T, K extends readonly (keyof T)[]> = {
  [P in K[number]]: T[P];
};

function pick<const T extends object, 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 user = { id: 1, name: "kim", active: true };

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

// pick(user, ["id", "missing"]); // 타입 오류

여기서 포인트는 keysreadonly (keyof T)[]로만 추론되면 K[number]가 너무 넓어져 결과 타입이 뭉개질 수 있다는 점입니다. const K로 “키 목록을 튜플처럼” 유지시키면 결과 타입이 정확해집니다.

const 타입 파라미터가 특히 잘 먹히는 패턴

다음 조건 중 하나라도 있으면 적용 후보입니다.

  • 배열/튜플 리터럴을 받아서 T[number] 같은 형태로 유니온을 만들 때
  • 객체 리터럴을 받아서 키/값 리터럴을 보존해야 할 때
  • “정의(define)” 함수 패턴(예: defineConfig, defineRoutes, defineSchema)을 만들 때
  • 호출부에서 as const를 반복적으로 강요하고 있을 때

반대로, 값이 동적으로 만들어져 리터럴 보존 자체가 의미 없거나(예: 사용자 입력), 너무 큰 객체/배열을 그대로 타입에 반영해 컴파일 비용이 커지는 경우는 신중해야 합니다.

마이그레이션 팁: 언제 as const를 없앨 수 있나

const 타입 파라미터를 도입했다고 해서 모든 as const가 사라지진 않습니다. 다음을 구분하는 게 좋습니다.

  • 함수 인자에서 리터럴을 보존하려는 목적as const는 상당 부분 제거 가능
  • 값 자체를 readonly로 고정하거나, 객체의 프로퍼티를 리터럴로 고정해서 다른 곳에서 재사용하려는 목적이라면 as const가 여전히 적절

예를 들어 다음은 함수 호출부에서는 as const가 필요 없을 수 있지만, 그 배열을 다른 곳에서도 “상수 튜플”로 재사용하려면 여전히 as const가 유효합니다.

const ROUTES = ["/", "/health", "/admin"] as const;

function defineRoutes<const T extends readonly string[]>(routes: T) {
  return routes;
}

const routes = defineRoutes(ROUTES);

주의사항: 타입이 너무 정밀해져 생기는 트레이드오프

리터럴 추론이 강해지면 좋은 점이 많지만, 다음 부작용도 고려해야 합니다.

  • 타입이 지나치게 커져 IDE/컴파일 성능에 영향
  • “적당히 넓은 타입”이 더 좋은 API도 있음(예: 플러그인 시스템에서 확장성을 위해 string을 허용)
  • 내부 구현에서 Object.fromEntries 같은 API를 쓰면 타입 단언(as)이 여전히 필요할 수 있음

즉 const 타입 파라미터는 “정밀한 타입이 제품 품질을 올리는 경계”에 쓰는 것이 가장 효율적입니다.

실전 적용 체크리스트

  • defineXxx 류 함수 시그니처에 const 타입 파라미터를 우선 적용
  • 반환 타입이 입력 리터럴에 의존하는지 확인(의존한다면 적용 가치 큼)
  • 호출부에서 반복되는 as const를 제거해 DX 개선
  • 타입이 과도하게 커지는지(특히 거대한 설정 객체) CI에서 체감 확인

프론트에서 Next.js를 쓰며 타입/캐시/렌더링 이슈까지 함께 다루는 팀이라면, 타입 안정성 개선이 디버깅 시간을 줄이는 데 직결됩니다. 관련 사례로 Next.js 14 RSC 캐시 꼬임으로 갱신이 안될 때 같은 글도 함께 참고하면 “원인 추적 비용”을 줄이는 관점에서 공감 포인트가 있을 겁니다.

정리

TS 5.5의 const 타입 파라미터는 “호출부가 리터럴 정보를 잃지 않게” 만드는 기능이라기보다, 정확히는 API 설계자가 리터럴 기반 타입 설계를 안정적으로 강제할 수 있게 해주는 장치입니다.

  • as const를 호출부에 강요하던 패턴을 시그니처로 끌어올 수 있고
  • 이벤트/라우트/스코프/키 목록 기반 유틸의 타입 정확도가 크게 올라가며
  • 팀 코드베이스에서 실수로 타입이 넓어지는 문제를 줄일 수 있습니다.

이미 defineConfig/defineRoutes 같은 패턴을 쓰고 있다면, TS 5.5 업그레이드 후 가장 먼저 적용해볼 만한 개선 포인트입니다.