Published on

TS 5.5+ satisfies로 타입추론 깨짐 잡는 법

Authors

서버/프론트 공통 코드에서 설정 객체, 라우트 맵, 이벤트 핸들러 테이블처럼 “객체 리터럴을 그대로 두고” 타입 안정성만 강하게 보장하고 싶을 때 satisfies는 거의 필수 도구가 됐습니다. 그런데 TS 5.5+로 올린 뒤부터, 혹은 코드베이스에서 satisfies 적용 범위를 넓히다 보면 타입추론이 기대와 다르게 깨지는 순간을 만나게 됩니다.

이 글에서는 satisfies가 정확히 무엇을 보장하고 무엇을 보장하지 않는지, 그리고 TS 5.5+에서 특히 자주 체감되는 “추론 깨짐” 케이스를 재현 코드와 함께 정리한 뒤, 실무에서 안전하게 쓰는 패턴을 제안합니다.

참고로 Next.js/ESM 환경에서 타입/모듈 경계가 복잡해질수록 이런 문제가 더 자주 드러납니다. 관련해서는 Node.js ESM 전환 후 exports import 오류 해결도 함께 보면 좋습니다.

satisfies의 핵심: “검증”이지 “주입”이 아니다

expr satisfies SomeType은 다음을 의도합니다.

  • exprSomeType할당 가능(assignable) 한지 검사한다
  • 검사 후에도 expr표현식 타입(expression type) 은 가능한 한 원래 추론된 타입을 유지한다

즉, as SomeType처럼 “강제로 타입을 바꾸는(주입하는)” 캐스팅이 아니라, “조건을 만족하는지 확인하는(검증하는)” 연산자입니다.

하지만 현실에서는 다음 이유로 “추론이 깨진 것처럼” 보일 수 있습니다.

  • satisfies 자체가 타입을 바꾸진 않지만, 주변 문맥 타입(contextual typing) 이 바뀌면 추론 결과가 달라진다
  • satisfies를 붙이는 위치가 바뀌면, 타입스크립트가 리터럴을 좁히는 방식이 달라진다
  • 인덱스 시그니처/레코드 타입을 만족시키려다 리터럴이 넓어져서(예: "GET"string으로) downstream 추론이 약해진다

케이스 1: Record/인덱스 시그니처로 리터럴이 넓어지는 문제

라우트/권한/피처플래그 같은 맵을 만들 때 흔히 이런 코드를 씁니다.

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

type RouteMap = Record<string, Route>;

const routes = {
  health: { method: "GET", path: "/health" },
  login: { method: "POST", path: "/login" },
} satisfies RouteMap;

여기까지는 좋아 보입니다. 문제는 routes를 가지고 키/값을 변환하거나, 키를 좁혀서 다른 제네릭에 넘길 때 발생합니다.

function pickRoute<K extends string>(map: Record<K, Route>, key: K) {
  return map[key];
}

const r = pickRoute(routes, "health");

기대: key"health" | "login" 같은 리터럴 유니온으로 좁혀져서 안전한 API가 되길 원함

현실: routes의 타입이 Record<string, Route>처럼 보이면, Kstring으로 추론되어 버려서 "health" 같은 리터럴 기반 안전성이 약해집니다.

해결 패턴: “키는 리터럴, 값은 검증”을 분리하기

가장 실용적인 방법은 키 리터럴을 보존할 타입검증할 타입을 분리하는 것입니다.

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

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

type RouteKey = keyof typeof routes; // "health" | "login"
  • as const로 키/값 리터럴을 최대한 보존
  • satisfies Record<string, Route>로 “값 구조가 Route를 만족하는지”만 검증

이 패턴은 라우트뿐 아니라 이벤트 핸들러 맵, 에러 코드 테이블, UI 카피 테이블에서 매우 자주 씁니다.

케이스 2: 제네릭 팩토리에서 satisfies를 잘못 붙여 추론이 약해지는 문제

다음은 실무에서 흔한 “정의-사용 분리” 패턴입니다.

type Handler<TPayload> = (payload: TPayload) => Promise<void>;

function defineHandlers<T extends Record<string, Handler<any>>>(handlers: T) {
  return handlers;
}

const handlers = defineHandlers({
  userCreated: async (p: { id: string }) => {},
  userDeleted: async (p: { id: string; hard?: boolean }) => {},
});

여기에 “모든 핸들러는 Handler<unknown>을 만족해야 한다” 같은 검증을 추가하려고 satisfies를 섞으면, 위치에 따라 추론이 달라집니다.

안 좋은 예: 객체 전체를 넓은 타입으로 만족시키기

type AnyHandlerMap = Record<string, Handler<unknown>>;

const handlers = defineHandlers({
  userCreated: async (p: { id: string }) => {},
  userDeleted: async (p: { id: string; hard?: boolean }) => {},
} satisfies AnyHandlerMap);

이 경우 defineHandlers의 제네릭 T가 “구체적인 핸들러 시그니처”가 아니라 AnyHandlerMap 쪽으로 끌려가면서, 이후에 handlers.userCreated의 파라미터 타입을 뽑아 쓰는 코드가 약해질 수 있습니다.

해결 패턴 A: defineHandlers 내부에서 검증하기

검증은 팩토리 내부로 넣고, 호출부에서는 리터럴 추론을 최대한 살립니다.

type Handler<TPayload> = (payload: TPayload) => Promise<void>;

type AnyHandlerMap = Record<string, Handler<unknown>>;

function defineHandlers<T extends AnyHandlerMap>(handlers: T) {
  // 여기서 이미 T는 AnyHandlerMap을 만족해야 함
  return handlers;
}

const handlers = defineHandlers({
  userCreated: async (p: { id: string }) => {},
  userDeleted: async (p: { id: string; hard?: boolean }) => {},
});

핵심은 호출부에서 satisfies를 “추론을 방해하는 방식”으로 붙이지 않는 것입니다.

해결 패턴 B: 2단계 선언으로 문맥 타입 분리

호출부에서 꼭 satisfies로 검증하고 싶다면, 한 번 변수로 받아 문맥 타입을 분리합니다.

type Handler<TPayload> = (payload: TPayload) => Promise<void>;

type AnyHandlerMap = Record<string, Handler<unknown>>;

function defineHandlers<T extends Record<string, Handler<any>>>(handlers: T) {
  return handlers;
}

const raw = {
  userCreated: async (p: { id: string }) => {},
  userDeleted: async (p: { id: string; hard?: boolean }) => {},
};

const handlers = defineHandlers(raw);

// 별도의 검증 단계(추론 결과는 raw/handlers에 남아 있음)
raw satisfies AnyHandlerMap;

이 패턴은 “정의는 최대한 구체적으로, 검증은 별도로”라는 원칙을 지키기 좋습니다.

케이스 3: 유니온/리터럴이 string으로 넓어져 분기 추론이 무너지는 문제

예를 들어 HTTP 메서드나 이벤트 타입을 리터럴로 유지하고 싶을 때입니다.

type EventSpec = {
  type: "CREATED" | "DELETED";
  version: 1 | 2;
};

type SpecMap = Record<string, EventSpec>;

const specs = {
  userCreatedV1: { type: "CREATED", version: 1 },
  userDeletedV2: { type: "DELETED", version: 2 },
} satisfies SpecMap;

이후 specs.userCreatedV1.type"CREATED"로 남아 있길 기대하지만, 코드 구성에 따라 "CREATED" | "DELETED" 혹은 string처럼 넓게 보일 수 있습니다.

해결 패턴: 값에만 satisfies를 적용하는 헬퍼

키/전체 맵에 Record<string, ...>를 적용하면 넓어지는 경향이 있으니, “각 값이 스펙을 만족하는지”를 더 직접적으로 표현합니다.

type EventSpec = {
  type: "CREATED" | "DELETED";
  version: 1 | 2;
};

const defineSpec = <T extends EventSpec>(spec: T) => spec;

const specs = {
  userCreatedV1: defineSpec({ type: "CREATED", version: 1 }),
  userDeletedV2: defineSpec({ type: "DELETED", version: 2 }),
} as const;
  • 각 값은 EventSpec을 만족해야 하므로 안전
  • 반환 타입은 T 그대로라 리터럴이 잘 보존됨

이 방식은 “테이블 전체를 큰 타입으로 검증”하는 대신 “원소 단위로 검증”해 추론 손실을 줄입니다.

TS 5.5+에서 체감이 커지는 이유(실무 관점)

TS 5.5 자체가 satisfies의 의미를 바꿨다기보다는, 다음 요소들이 겹치면서 업그레이드 이후 문제가 눈에 띄는 경우가 많습니다.

  • noUncheckedIndexedAccess 같은 옵션을 켜면서 Record/인덱싱 결과가 더 보수적으로 변함
  • exactOptionalPropertyTypes로 인해 “옵셔널”의 의미가 엄격해져, 만족 검사에서 넓은 타입으로 맞추려는 압력이 커짐
  • 라이브러리 타입 정의가 업데이트되며(특히 프레임워크/라우팅/ORM), 문맥 타입이 이전보다 더 강하게 걸림

이런 변화는 타입 시스템이 더 안전해지는 방향이지만, “리터럴 기반 DX”를 기대하던 코드에서는 추론 약화로 보일 수 있습니다.

실무 체크리스트: satisfies를 쓸 때 추론을 지키는 규칙

1) Record<string, X>로 전체 객체를 한 번에 덮지 말기

  • 키 유니온(keyof typeof obj)을 얻어야 한다면 특히 주의
  • 가능하면 as const와 함께 사용하거나, 원소 단위 헬퍼로 검증

2) 팩토리/빌더 함수 호출 인자에 satisfies를 직접 붙이는 건 신중히

  • 제네릭 추론이 “검증 타입” 쪽으로 끌려갈 수 있음
  • 내부 제약(T extends ...)으로 옮기거나, 2단계 선언으로 분리

3) as는 최후의 수단, satisfies는 검증용

  • as SomeType은 추론을 고정(주입)해 버려 잘못된 값도 통과시킬 수 있음
  • satisfies는 틀리면 컴파일 에러를 내므로 안전하지만, 문맥 타입 변화는 만들 수 있음

4) “검증 타입”은 가능한 한 좁게, “추론 타입”은 가능한 한 구체적으로

  • 검증 타입을 너무 넓게 잡으면(예: Record<string, ...>) 리터럴 정보가 손실되기 쉬움

예제: 설정 객체에서 satisfies로 안전성과 DX 둘 다 잡기

환경별 설정을 다루는 예시로 마무리해보겠습니다.

type Env = "dev" | "prod";

type AppConfig = {
  apiBaseUrl: string;
  logLevel: "debug" | "info" | "warn" | "error";
};

const configByEnv = {
  dev: {
    apiBaseUrl: "http://localhost:3000",
    logLevel: "debug",
  },
  prod: {
    apiBaseUrl: "https://api.example.com",
    logLevel: "info",
  },
} as const satisfies Record<Env, AppConfig>;

export function getConfig(env: Env) {
  return configByEnv[env];
}

const c = getConfig("dev");
// c.logLevel은 "debug" | "info" ... 처럼 넓어질 수 있지만,
// 최소한 configByEnv의 키는 Env로 강제되고, 값 구조도 AppConfig로 검증됨

여기서 더 강한 리터럴 DX가 필요하다면(예: dev에서는 logLevel이 반드시 "debug"로 유지되길 원함) getConfig를 제네릭으로 바꾸는 식으로 “사용 지점”에서 좁히는 전략을 씁니다.

export function getConfig<E extends Env>(env: E) {
  return configByEnv[env];
}

const devConfig = getConfig("dev");
// devConfig.logLevel: "debug"

마무리

TS 5.5+에서 satisfies를 쓰다 “타입추론이 깨졌다”고 느끼는 대부분의 케이스는, satisfies 자체의 문제가 아니라 문맥 타입과 검증 타입의 설계가 추론을 압박하는 데서 출발합니다.

정리하면,

  • 리터럴/키 유니온을 살리고 싶으면 as const와 함께 쓰거나 원소 단위 검증을 고려
  • 팩토리/제네릭 경계에서는 satisfies를 호출부에 직접 붙이지 말고 제약으로 옮기기
  • Record<string, ...> 같은 넓은 검증은 “편하지만” 추론 손실을 유발하기 쉬움

타입 시스템 이슈는 종종 빌드/런타임 디버깅과 연결됩니다. CI에서 버전이 섞여 재현이 어려운 경우라면 GitHub Actions 캐시 충돌로 CI 간헐 실패 디버깅처럼 환경 고정 전략도 함께 점검해보면 좋습니다.