Published on

TS 5.x 초고급 타입 추론 - satisfies·infer·const

Authors

서버/프론트가 커질수록 타입은 단순히 “에러를 막는 도구”가 아니라, 리팩터링 속도API 설계 품질을 좌우하는 핵심 자산이 됩니다. TypeScript 5.x는 타입 시스템을 더 “표현력 있게” 만들면서도, 런타임 코드를 늘리지 않는 방향으로 진화했고 그 중심에 satisfies, infer, 그리고 다양한 의미의 const가 있습니다.

이 글에서는 세 기능을 각자 따로가 아니라, 실제 코드에서 서로 조합해 추론을 극대화하는 방식으로 정리합니다.

참고: Next.js 앱에서 타입 설계를 다듬는 맥락이 궁금하다면 Next.js 14 서버컴포넌트와 클라이언트 상태 동기화도 함께 보면 좋습니다.

1) satisfies: “검증”과 “추론 유지”를 동시에

1-1. as 캐스팅의 문제: 추론을 죽이거나, 타입 안전을 죽이거나

많은 코드에서 설정 객체나 라우트 테이블을 만들 때 아래처럼 작성합니다.

type Route = {
  path: string;
  method: "GET" | "POST";
};

const routes: Record<string, Route> = {
  health: { path: "/health", method: "GET" },
  createUser: { path: "/users", method: "POST" },
};

이 방식은 “검증”은 되지만, routes.health.method 같은 값의 리터럴 정보가 잘 보존되지 않는 경우가 많습니다. Record<string, Route>로 한 번 감싸는 순간, 키도 string으로 넓어지고 내부도 넓어지기 쉽습니다.

반대로 as Route 같은 캐스팅을 남발하면, 실수로 method: "PUT"을 넣어도 컴파일러가 눈감아버릴 수 있습니다.

1-2. satisfies의 핵심: 타입을 “강제”하지 않고 “충족 여부만” 검사

routes의 실제 타입 추론은 유지하면서, 동시에 특정 인터페이스를 만족하는지 검증하고 싶다면 satisfies가 정답입니다.

type Route = {
  path: `/${string}`;
  method: "GET" | "POST";
};

type RouteTable = Record<string, Route>;

const routes = {
  health: { path: "/health", method: "GET" },
  createUser: { path: "/users", method: "POST" },
} satisfies RouteTable;

이제

  • routes.health.method는 가능한 한 리터럴로 유지됩니다.
  • path는 반드시 /로 시작해야 합니다(템플릿 리터럴 타입 검증).
  • routes의 키는 여전히 "health" | "createUser" 같은 구체적 유니온으로 남습니다.

1-3. satisfies + as const: “값을 고정”하고 “구조를 검증”

as const는 값을 최대한 리터럴로 고정합니다. 다만 as const만 쓰면 구조 검증이 약해질 수 있으니 satisfies와 함께 쓰면 좋습니다.

type FeatureFlag = {
  owner: string;
  rollout: 0 | 10 | 50 | 100;
};

type FeatureFlags = Record<string, FeatureFlag>;

const flags = {
  newCheckout: { owner: "pay-team", rollout: 10 },
  fastSearch: { owner: "search-team", rollout: 50 },
} as const satisfies FeatureFlags;

여기서 얻는 이점:

  • flags.newCheckout.rollout10으로 고정
  • rollout30을 넣으면 즉시 타입 에러
  • 키는 "newCheckout" | "fastSearch"로 유지

이 패턴은 설정/메타데이터 테이블, 권한 매트릭스, 이벤트 스키마 목록에서 특히 강력합니다.

2) infer: 타입에서 “부분을 뽑아내는” 고급 추론 도구

infer는 조건부 타입(T extends ... ? ... : ...) 안에서만 사용할 수 있는 키워드로, “어떤 타입의 일부를 변수처럼 바인딩”해 재사용할 수 있게 해줍니다.

2-1. 함수 반환 타입/인자 타입 뽑기 (직접 구현해보기)

표준 라이브러리에 ReturnType, Parameters가 있지만, 직접 만들어 보면 infer의 감각이 잡힙니다.

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

type MyParameters<T> = T extends (...args: infer P) => any ? P : never;

function fetchUser(id: string) {
  return { id, name: "kim" as const };
}

type R1 = MyReturnType<typeof fetchUser>; // { id: string; name: "kim" }
type P1 = MyParameters<typeof fetchUser>; // [id: string]

2-2. Promise 언랩: Awaited를 이해하는 방식

비동기 코드에서 자주 필요한 “Promise 내부 타입” 추출도 infer로 가능합니다.

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<number>>; // number
type B = UnwrapPromise<string>; // string

실제로는 중첩 Promise, thenable 등을 고려해야 해서 TS 내장 Awaited가 더 안전하지만, 기본 원리는 동일합니다.

2-3. 템플릿 리터럴 타입 + infer: 문자열 스키마 파싱

TS 5.x에서 문자열 기반 규칙을 타입으로 강제하는 패턴이 많이 쓰입니다. 예를 들어 "user:create" 같은 이벤트 이름을 "domain:action"으로 강제하고 싶다고 합시다.

type ParseEvent<T extends string> =
  T extends `${infer Domain}:${infer Action}`
    ? { domain: Domain; action: Action }
    : never;

type E1 = ParseEvent<"user:create">; // { domain: "user"; action: "create" }
type E2 = ParseEvent<"invalid">; // never

이걸 기반으로 이벤트 핸들러 맵을 더 안전하게 만들 수 있습니다.

type EventName = "user:create" | "user:delete" | "order:paid";

type HandlerMap = {
  [K in EventName]: (payload: ParseEvent<K>) => void;
};

const handlers: HandlerMap = {
  "user:create": (p) => {
    // p.domain === "user", p.action === "create"
  },
  "user:delete": (p) => {},
  "order:paid": (p) => {},
};

3) TS 5.x의 const: as const만 있는 게 아니다

TypeScript에서 const는 문맥에 따라 의미가 다릅니다. TS 5.x를 “완전 정복”하려면 아래 3가지를 구분해야 합니다.

  1. 값 레벨의 const 선언
  2. 타입 레벨의 as const (const assertion)
  3. 제네릭/타입 추론을 강화하는 const 관련 기능(예: const type parameter)

3-1. as const: 리터럴을 최대한 좁히는 고전적 핵심

const roles = ["admin", "member", "guest"] as const;
// readonly ["admin", "member", "guest"]

type Role = (typeof roles)[number];
// "admin" | "member" | "guest"

as const는 “배열을 튜플로”, “프로퍼티를 readonly로”, “값을 리터럴로” 좁히는 역할을 합니다.

3-2. const type parameter: 인자로 들어온 리터럴을 더 잘 보존

일반적으로 제네릭 함수는 인자를 받으면 타입이 넓어지기 쉽습니다. TS 5.x에서는 const type parameter를 통해 “제네릭 추론에서 리터럴을 더 잘 유지”할 수 있습니다.

// TS 5.x
function pick<const T extends object, 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: "u1", name: "kim", age: 20 };

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

여기서 keysstring[]으로 쉽게 넓어지지 않고 readonly ["id", "name"]에 가깝게 유지되어 Pick이 정확해집니다.

주의: 프로젝트의 TS 버전/설정에 따라 체감이 다를 수 있습니다. 하지만 대규모 코드베이스에서 “유틸 함수 하나가 추론을 얼마나 잘 유지하느냐”는 생산성에 직결됩니다.

3-3. const + satisfies 조합으로 “키 유니온”을 잃지 않는 레지스트리 만들기

플러그인/커맨드 레지스트리를 만든다고 해봅시다. 목표는 다음입니다.

  • 등록 객체의 키를 유니온으로 얻고 싶다
  • 각 엔트리는 특정 스키마를 만족해야 한다
  • 각 엔트리의 세부 값은 리터럴로 유지되면 좋다
type CommandSpec = {
  description: string;
  args: readonly string[];
};

type CommandRegistry = Record<string, CommandSpec>;

const commands = {
  build: { description: "Build project", args: ["--prod"] },
  dev: { description: "Start dev server", args: ["--port"] },
} as const satisfies CommandRegistry;

type CommandName = keyof typeof commands;
// "build" | "dev"

type BuildArgs = (typeof commands)["build"]["args"][number];
// "--prod"

이 패턴은 CLI, 작업 큐, 이벤트 라우터, API 엔드포인트 메타데이터에 그대로 적용할 수 있습니다.

4) satisfies vs as const vs 타입 주석: 언제 무엇을 쓰나

4-1. 선택 가이드

  • 값을 리터럴로 고정하고 싶다: as const
  • 특정 타입을 만족하는지 검사하면서도 추론은 유지하고 싶다: satisfies
  • **그 변수/상수의 타입을 특정 타입으로 “고정”**하고 싶다: 타입 주석(: Type)
  • 검증 없이 통과시키고 싶다(대부분 나쁜 선택): as Type

4-2. 실전 체크리스트

  • 설정 객체/테이블: as const satisfies SomeSchema
  • 외부 입력(런타임 검증 후): 검증 결과 타입을 좁히고, 그 다음엔 satisfies로 구조 유지
  • 유틸 함수: 가능하면 const type parameter로 리터럴 보존

5) 고급 예제: 타입 안전한 “API 스펙”에서 클라이언트 타입 자동 추론

프론트에서 API 호출을 만들 때, 아래 요구가 자주 등장합니다.

  • 엔드포인트 목록을 한 곳에서 관리
  • 메서드/경로/요청/응답 타입을 강제
  • 호출 함수에서 path를 선택하면 요청/응답 타입이 자동으로 따라오게

5-1. 스펙 정의: satisfies로 스키마 검증

type ApiSpec = {
  method: "GET" | "POST";
  path: `/${string}`;
  // 단순화를 위해 request/response를 unknown으로 두고, 실제로는 zod 등과 결합 가능
  request: unknown;
  response: unknown;
};

type ApiTable = Record<string, ApiSpec>;

const api = {
  getUser: {
    method: "GET",
    path: "/users",
    request: { id: "" as string },
    response: { id: "" as string, name: "" as string },
  },
  createUser: {
    method: "POST",
    path: "/users",
    request: { name: "" as string },
    response: { id: "" as string },
  },
} satisfies ApiTable;

여기서 api는 스키마를 만족해야 하지만, 키 유니온("getUser" | "createUser")은 유지됩니다.

5-2. infer로 요청/응답을 추출하는 클라이언트

type SpecOf<K extends keyof typeof api> = (typeof api)[K];

type RequestOf<K extends keyof typeof api> = SpecOf<K> extends { request: infer R }
  ? R
  : never;

type ResponseOf<K extends keyof typeof api> = SpecOf<K> extends { response: infer R }
  ? R
  : never;

async function callApi<K extends keyof typeof api>(
  key: K,
  req: RequestOf<K>
): Promise<ResponseOf<K>> {
  // 실제 구현에서는 fetch/axios 등을 사용
  // 여기서는 타입 데모용
  return api[key].response as ResponseOf<K>;
}

// 사용 예
const u = await callApi("getUser", { id: "u1" });
// u: { id: string; name: string }

const created = await callApi("createUser", { name: "kim" });
// created: { id: string }

이 구조의 장점은 “스펙이 단일 진실 공급원(Single Source of Truth)”이 되고, 호출부에서 문자열 키만 선택해도 요청/응답이 자동으로 고정된다는 점입니다.

6) 타입 추론을 망치는 흔한 함정과 해결

6-1. Record<string, ...>를 너무 일찍 씌우기

초기에 const x: Record<string, T> = ... 형태로 고정하면 키가 string으로 넓어져서 이후 keyof typeof x의 가치가 떨어집니다.

해결:

  • 먼저 객체 리터럴을 만들고
  • satisfies로 검증만 걸고
  • 키 유니온은 keyof typeof로 가져옵니다.

6-2. “타입을 맞추기 위해” as로 눌러버리기

as는 마지막 수단이어야 합니다. 특히 스키마/레지스트리/라우트 테이블에서 as는 장기적으로 타입 부채를 만듭니다.

해결:

  • satisfies로 바꾸고
  • 필요한 곳에만 as const
  • 유틸 함수에는 const type parameter를 고려

6-3. 문자열 규칙을 런타임에만 의존하기

예: 이벤트 이름이 "domain:action" 규칙이라면, 타입에서 템플릿 리터럴 + infer로 강제해두면 API 사용성이 좋아집니다.

7) 마무리: 세 키워드를 “조합”했을 때 얻는 것

  • satisfies: 구조 검증을 하면서 추론을 유지
  • infer: 타입에서 필요한 조각을 뽑아 자동 전파
  • const(특히 as const, const type parameter): 리터럴/튜플 정보를 유지해 정밀한 추론

이 세 가지를 함께 쓰면, “타입이 귀찮은 장식”이 아니라 도메인 규칙을 컴파일 타임에 고정하는 설계 도구가 됩니다. 특히 레지스트리/스펙/라우팅/이벤트 같은 메타데이터 중심 코드에서 효과가 큽니다.

추가로, 추론/에이전트 설계 관점에서 “정보를 어떻게 구조화해 누출 없이 추론하게 만들까”에 관심이 있다면 CoT 누출 없이 ReAct·스크래치패드로 추론도 같이 읽어보면 사고방식에 도움이 됩니다.