Published on

TypeScript 5.5 infer·satisfies로 타입오류 줄이기

Authors

서버·프론트 모두 TypeScript를 쓰다 보면, 타입오류는 크게 두 부류로 나뉩니다.

  • 진짜 버그를 잡아주는 오류: 반드시 남겨야 합니다.
  • 타입 모델링 때문에 생기는 소음(noise): 생산성을 갉아먹고, 결국 any로 도망가게 만듭니다.

TypeScript 5.5에서도 핵심은 동일합니다. 추론(infer)을 최대한 살리면서, 필요한 지점에서만 계약을 강제(satisfies) 해서 “소음은 줄이고 신호는 키우는” 방향으로 타입을 설계해야 합니다.

이 글에서는 infersatisfies를 실무 패턴으로 엮어, 다음을 목표로 합니다.

  • 객체/맵/라우트 정의에서 타입 넓어짐(widening) 을 막고
  • 구현체는 자유롭게 작성하되 요구 스펙은 컴파일 타임에 검증하고
  • 제네릭 유틸에서 반환 타입을 정확히 추론해 불필요한 캐스팅을 제거하기

참고로, 타입 정의가 느슨하면 런타임에서 스키마 검증 에러가 터지기도 합니다. 예를 들어 API 스키마가 맞지 않아 422가 나는 상황은 타입 단계에서 상당 부분 예방할 수 있습니다. 관련해서는 OpenAI Responses API 422 스키마 검증 에러 해결 가이드도 함께 보면 맥락이 잘 맞습니다.

infer: “타입을 뽑아내는” 도구를 실무적으로 쓰는 법

infer는 조건부 타입에서 특정 위치의 타입을 추출할 때 씁니다. 문법 자체는 예전부터 있었지만, 실무에서 중요한 건 “어떤 타입을 추출해두면 캐스팅과 중복 선언을 줄일 수 있나”입니다.

1) 함수 반환 타입/인자 타입을 유틸로 추출하기

가장 흔한 패턴은 함수 시그니처에서 필요한 조각을 뽑아내 재사용하는 것입니다.

// 함수 타입에서 첫 번째 인자 타입을 추출
export type FirstArg<F> = F extends (a: infer A, ...rest: any[]) => any ? A : never;

// Promise 반환을 unwrap
export type AwaitedReturn<F> = F extends (...args: any[]) => Promise<infer R> ? R : never;

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

type FetchUserArg = FirstArg<typeof fetchUser>; // string
type FetchUserResult = AwaitedReturn<typeof fetchUser>; // { readonly id: string; readonly name: "Kim" }

이런 유틸을 만들어두면, API 클라이언트/서버 핸들러 사이에서 타입을 “복붙”하지 않아도 됩니다.

2) 유니언에서 특정 형태만 골라내기

이벤트/액션 모델링에서 infer는 특히 강력합니다.

type Event =
  | { type: "click"; x: number; y: number }
  | { type: "submit"; formId: string }
  | { type: "view"; page: string };

// 특정 type 값에 해당하는 payload만 추출
export type EventOf<TType extends Event["type"]> =
  Event extends { type: TType } ? Event : never;

type SubmitEvent = EventOf<"submit">; // { type: "submit"; formId: string }

여기서는 infer가 직접 보이진 않지만, 실무에서는 이런 “형태 기반 필터링”을 infer와 함께 써서 더 정교하게 만들 수 있습니다(예: payload만 뽑기).

type PayloadOf<E> = E extends { payload: infer P } ? P : never;

type Action =
  | { type: "inc"; payload: { by: number } }
  | { type: "set"; payload: { value: number } };

type IncPayload = PayloadOf<Extract<Action, { type: "inc" }>>; // { by: number }

satisfies: “추론은 유지하고, 요구사항만 검증”하기

satisfies의 핵심은 다음 한 줄로 요약됩니다.

  • as SomeType값의 타입을 강제로 바꿔버려서 추론 정보를 잃기 쉽고
  • : SomeType 주석(annotation)은 추론을 SomeType에 맞춰 제한해버릴 수 있지만
  • satisfies SomeType값은 값대로 최대한 구체적으로 추론하면서, 동시에 SomeType을 만족하는지 검사합니다.

1) 객체 리터럴에서 "타입 넓어짐"을 막고 계약만 강제

예를 들어 라우트 테이블을 만든다고 합시다.

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

const routes = {
  home: { path: "/", method: "GET" },
  save: { path: "/save", method: "POST" },
} satisfies Record<string, RouteSpec>;

여기서 좋은 점:

  • routes.home.method는 여전히 리터럴 타입 "GET"으로 유지됩니다.
  • 하지만 method: "PUT" 같은 실수는 컴파일 단계에서 잡힙니다.

만약 아래처럼 타입 주석으로 막아버리면, 내부 값이 전부 "GET" | "POST"로 넓어져서 이후 로직에서 분기 최적화가 어려워질 수 있습니다.

// 주석(annotation)은 추론을 제한할 수 있음
const routes2: Record<string, RouteSpec> = {
  home: { path: "/", method: "GET" },
  save: { path: "/save", method: "POST" },
};

// routes2.home.method: "GET" | "POST" (리터럴이 유지되지 않을 수 있음)

2) 키 목록을 정확히 유지하면서 스펙 검증

실무에서 “키를 문자열로 뽑아 쓰는” 경우가 많습니다. 예: 권한, 피처 플래그, 이벤트 이름.

type FeatureFlagSpec = {
  description: string;
  default: boolean;
};

const featureFlags = {
  newCheckout: { description: "새 결제 플로우", default: false },
  fastSearch: { description: "검색 최적화", default: true },
} satisfies Record<string, FeatureFlagSpec>;

type FeatureFlagKey = keyof typeof featureFlags;
// "newCheckout" | "fastSearch"

이 패턴의 포인트는 keyof typeof featureFlags를 통해 키 유니언을 자동 생성하고, 스펙은 satisfies로 강제한다는 점입니다.

infer + satisfies 조합: "정의는 엄격하게, 사용은 편하게"

이제 두 기능을 엮으면, 타입오류를 줄이는 실전 패턴이 나옵니다.

1) 핸들러 맵: 구현체는 자유롭게, 시그니처는 강제

이벤트 핸들러를 맵으로 정의할 때, 흔한 문제는 다음입니다.

  • 핸들러 시그니처가 조금씩 어긋나는데도 any로 통과
  • 혹은 반대로 타입을 너무 강하게 고정해서 구현이 불편

satisfies로 “필수 계약”만 체크하고, infer로 호출부 타입을 뽑아내면 균형이 좋아집니다.

type HandlerMap = Record<string, (payload: any) => any>;

type PayloadOfHandler<F> = F extends (payload: infer P) => any ? P : never;

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

const handlers = {
  add: (payload: { a: number; b: number }) => payload.a + payload.b,
  greet: (payload: { name: string }) => `hello ${payload.name}`,
} satisfies HandlerMap;

type AddPayload = PayloadOfHandler<(typeof handlers)["add"]>; // { a: number; b: number }
type GreetResult = ResultOfHandler<(typeof handlers)["greet"]>; // string

function call<K extends keyof typeof handlers>(
  key: K,
  payload: PayloadOfHandler<(typeof handlers)[K]>
): ResultOfHandler<(typeof handlers)[K]> {
  return handlers[key](payload);
}

const n = call("add", { a: 1, b: 2 });
// call("add", { a: 1 }); // 컴파일 에러

핵심은 handlers 자체는 객체 리터럴로 자연스럽게 작성하고, call 같은 공용 함수가 infer를 통해 정확한 payload/result를 끌어오게 하는 것입니다.

2) “스키마 정의 객체”의 정확성 확보: 런타임 에러를 타입 단계로

백엔드/프론트 경계에서 JSON 스키마나 응답 형태 정의를 객체로 들고 있는 경우가 많습니다. 이때 satisfies로 최소 요구사항을 강제하고, infer로 타입을 추출해 재사용하면 중복이 줄어듭니다.

type JsonField =
  | { kind: "string" }
  | { kind: "number" }
  | { kind: "boolean" };

type Schema = Record<string, JsonField>;

type TypeFromField<F> =
  F extends { kind: "string" } ? string :
  F extends { kind: "number" } ? number :
  F extends { kind: "boolean" } ? boolean :
  never;

type TypeFromSchema<S extends Schema> = {
  [K in keyof S]: TypeFromField<S[K]>;
};

const userSchema = {
  id: { kind: "string" },
  age: { kind: "number" },
  isAdmin: { kind: "boolean" },
} satisfies Schema;

type User = TypeFromSchema<typeof userSchema>;
// { id: string; age: number; isAdmin: boolean }

이 방식은 “정의 객체”가 커질수록 효과가 커집니다. 스키마 변경 시 컴파일 에러로 영향 범위를 좁힐 수 있어, 런타임 422 같은 스키마 불일치 문제를 더 일찍 잡는 데 도움이 됩니다.

TypeScript 5.5에서 특히 체감되는 포인트

TypeScript는 버전이 올라가면서 추론과 검사 규칙이 점진적으로 개선됩니다. 5.5에서도 satisfies를 활용한 패턴은 여전히 강력합니다. 특히 다음 상황에서 체감이 큽니다.

  • 객체 리터럴을 “설정”처럼 들고 가면서도, 각 값의 리터럴 타입을 유지하고 싶을 때
  • 키 유니언을 자동 생성해 라우팅/이벤트/권한을 한 곳에서 관리하고 싶을 때
  • 제네릭 유틸을 만들어 호출부에서 타입 캐스팅을 없애고 싶을 때

프론트에서는 이런 타입 불일치가 결국 런타임 UI 문제로 이어질 수 있습니다. 예를 들어 서버/클라이언트 렌더 결과가 미묘하게 달라지는 문제를 추적할 때도, 데이터 모델의 타입 정확도가 디버깅 시간을 줄입니다. 관련해서는 Next.js Hydration mismatch 원인 9가지와 해결법도 같이 참고하면 좋습니다.

실전 체크리스트: 타입오류를 “줄이는” 방향으로 쓰기

1) as 캐스팅을 먼저 의심하기

as SomeType가 많아질수록 타입 시스템이 제공하는 보호막이 얇아집니다. 다음 중 하나라면 satisfies로 바꿀 여지가 큽니다.

  • 객체 리터럴을 특정 인터페이스로 “맞춰 넣는” 캐스팅
  • 설정/테이블/레지스트리 형태의 상수 정의
// 나쁜 예: 캐스팅으로 검증을 회피하기 쉬움
const config = {
  retry: 3,
  mode: "fast",
} as { retry: number; mode: "fast" | "safe" };

// 좋은 예: 추론 유지 + 스펙 검증
const config2 = {
  retry: 3,
  mode: "fast",
} satisfies { retry: number; mode: "fast" | "safe" };

2) “정의”와 “사용” 사이에 추출 타입을 둔다

정의 객체에서 타입을 추출하는 한 단계(infer 기반 유틸 또는 typeof 기반 매핑)를 두면, 호출부에서 타입 주석이 줄어듭니다.

  • 정의: satisfies로 계약 검증
  • 사용: infer로 payload/result/schema 타입 추출

3) 에러 메시지가 길어지면 타입을 쪼갠다

조건부 타입 + infer는 복잡해지기 쉬워 에러 메시지가 난해해질 수 있습니다. 이때는 중간 유틸 타입을 분리해 디버깅 가능성을 높이세요.

// 한 번에 다 쓰지 말고
export type Payload<F> = F extends (payload: infer P) => any ? P : never;
export type Result<F> = F extends (...args: any[]) => infer R ? R : never;

마무리

infer는 “타입을 뽑아내는 기술”이고, satisfies는 “추론을 망치지 않고 요구사항을 검증하는 기술”입니다. 둘을 함께 쓰면 다음 효과가 분명해집니다.

  • 타입 정의 중복 감소
  • 불필요한 캐스팅 감소
  • 객체 기반 레지스트리(라우트/이벤트/권한/스키마)의 안정성 증가
  • 런타임 스키마 불일치 같은 문제를 컴파일 단계로 당김

다음 리팩터링 때는 as로 눌러버린 타입을 하나씩 satisfies로 바꾸고, 호출부에서 반복되는 타입 주석은 infer 유틸로 걷어내 보세요. 타입오류의 “양”이 줄어드는 게 아니라, 의미 없는 오류가 줄고 의미 있는 오류만 남는 쪽으로 코드베이스가 정리될 것입니다.