Published on

TS 5.6+ satisfies로 타입 추론 깨짐 잡기

Authors

서로 다른 모듈 경계(라우트 테이블, 이벤트 맵, 설정 객체, DI 레지스트리 등)에서 타입 추론이 한 번 깨지기 시작하면, 이후 코드는 string, any, 넓은 유니온으로 퍼지며 “타입이 있는데도 자동완성/검사가 약해지는” 현상이 생깁니다. TypeScript의 대표적인 함정은 다음 두 가지입니다.

  • 객체 리터럴에 타입 주석을 붙이는 순간 값이 넓게(widening) 평가되어 리터럴 타입이 사라짐
  • Record<string, ...> 같은 넓은 인덱스 시그니처로 강제하면 키/값의 구체성이 증발

TypeScript 4.9부터 도입된 satisfies는 이 문제를 “검증은 하되, 추론은 그대로 유지”하는 방식으로 풀어줍니다. 그리고 TS 5.6+로 오면서 라이브러리/프레임워크 코드에서 이 패턴을 전면 적용하기가 더 쉬워졌습니다(특히 const 객체, as const, 제네릭 헬퍼와 조합 시).

이 글에서는 satisfies가 왜 타입 추론을 살리는지, 어디에서 실제로 추론이 깨지는지, 그리고 TS 5.6+ 기준으로 추천하는 패턴을 예제 중심으로 정리합니다.

참고: TS 설정을 손보는 흐름이 필요하다면 TS 5.5+ - noUncheckedSideEffectImports 해결 가이드도 함께 보면 좋습니다. 프로젝트 전반의 타입/빌드 안정성을 같이 끌어올릴 수 있습니다.

satisfies가 해결하는 핵심: “검증”과 “추론” 분리

TypeScript에서 아래 두 코드는 겉보기엔 비슷하지만 결과가 크게 다릅니다.

타입 주석(:)은 추론을 고정/확장시킨다

type RouteConfig = {
  path: string;
  auth: boolean;
};

// 타입 주석을 붙이면, 내부 리터럴이 넓어질 수 있음
const routes: Record<string, RouteConfig> = {
  home: { path: "/", auth: false },
  admin: { path: "/admin", auth: true },
};

// routes.home.path 타입은 string ("/" 리터럴이 사라짐)

Record<string, RouteConfig>로 “형태는 맞다”를 강제했지만, 그 대가로 home, admin 같은 키의 리터럴 정보"/", "/admin" 같은 값의 리터럴 정보가 대부분 소실됩니다.

satisfies는 형태만 검증하고, 타입은 그대로 둔다

type RouteConfig = {
  path: string;
  auth: boolean;
};

const routes = {
  home: { path: "/", auth: false },
  admin: { path: "/admin", auth: true },
} satisfies Record<string, RouteConfig>;

// routes.home.path 타입은 "/" (리터럴 유지)
// routes.admin.auth 타입은 true (리터럴 유지)

즉, satisfies는 다음을 동시에 얻습니다.

  • 객체가 RouteConfig 형태를 만족하는지 검사
  • 객체 자체는 가능한 한 구체적으로 추론(리터럴/좁은 타입 유지)

이 차이가 “추론 깨짐”을 잡는 출발점입니다.

흔한 추론 깨짐 1: 설정 객체에서 Record<string, ...>를 쓰는 순간

실무에서 가장 많이 보는 형태가 “설정 맵”입니다.

  • 기능 플래그
  • 환경별 엔드포인트
  • 권한/롤 정책
  • 이벤트 핸들러 레지스트리

나쁜 예: 넓은 인덱스 시그니처가 모든 걸 string으로 만든다

type FeatureFlag = {
  owner: "core" | "growth";
  rollout: number; // 0..100
};

const flags: Record<string, FeatureFlag> = {
  newCheckout: { owner: "growth", rollout: 10 },
  fasterSearch: { owner: "core", rollout: 100 },
};

// keyof typeof flags 는 string
// flags.newCheckout.owner 는 "core" | "growth" (리터럴 정보 약화)

keyof typeof flagsstring이 되어버리면, 이후 “키 기반 API”가 전부 약해집니다.

좋은 예: satisfies로 키/값 리터럴 유지

type FeatureFlag = {
  owner: "core" | "growth";
  rollout: number;
};

const flags = {
  newCheckout: { owner: "growth", rollout: 10 },
  fasterSearch: { owner: "core", rollout: 100 },
} satisfies Record<string, FeatureFlag>;

type FlagName = keyof typeof flags;
// FlagName = "newCheckout" | "fasterSearch"

function isEnabled(name: FlagName) {
  return flags[name].rollout > 0;
}

여기서 포인트는 Record<string, FeatureFlag>를 “타입으로 선언”하지 않고, “만족 조건”으로만 쓰는 것입니다.

흔한 추론 깨짐 2: 유니온/디스크리미네이티드 유니온이 string으로 퍼짐

예를 들어 이벤트 시스템을 만들 때, 이벤트 이름과 페이로드 타입을 강하게 묶고 싶습니다.

목표: 이벤트 이름에 따라 payload 타입이 자동으로 바뀌게

type EventMap = {
  "user:signedUp": { userId: string; email: string };
  "order:paid": { orderId: string; amount: number };
};

function emit<E extends keyof EventMap>(event: E, payload: EventMap[E]) {
  // ...
}

여기까지는 좋습니다. 문제는 “핸들러 테이블”을 만들 때 자주 발생합니다.

나쁜 예: 핸들러 테이블에 타입 주석을 붙이며 추론이 무너짐

type HandlerMap = {
  [K in keyof EventMap]: (payload: EventMap[K]) => void;
};

const handlers: Partial<HandlerMap> = {
  "user:signedUp": (payload) => {
    payload.email;
  },
  "order:paid": (payload) => {
    payload.amount;
  },
};

겉보기엔 괜찮지만, 더 복잡해지면(옵셔널 처리, 공통 미들웨어, 래핑 함수) payloadEventMap[keyof EventMap] 같은 넓은 유니온으로 붕괴하는 경우가 많습니다.

좋은 예: satisfies로 각 프로퍼티별 함수 시그니처를 검증

type HandlerMap = {
  [K in keyof EventMap]: (payload: EventMap[K]) => void;
};

const handlers = {
  "user:signedUp": (payload: { userId: string; email: string }) => {
    payload.email;
  },
  "order:paid": (payload: { orderId: string; amount: number }) => {
    payload.amount;
  },
} satisfies Partial<HandlerMap>;

type EventName = keyof typeof handlers;

핸들러 객체는 그대로 구체적으로 추론되면서도, Partial<HandlerMap> 조건을 만족하지 못하면 컴파일 타임에 바로 잡힙니다.

TS 5.6+에서 특히 유용한 패턴: “리터럴 유지 + 형태 강제” 조합

실전에서는 satisfies 단독이 아니라 아래 조합이 강력합니다.

  • as const로 리터럴을 고정
  • satisfies로 구조를 검증
  • keyof typeof로 키 유니온을 뽑아 API의 입력을 제한

라우트 테이블 예제

type Route = {
  path: string;
  method: "GET" | "POST";
  auth: "public" | "user" | "admin";
};

const routeTable = {
  home: { path: "/", method: "GET", auth: "public" },
  profile: { path: "/me", method: "GET", auth: "user" },
  adminUsers: { path: "/admin/users", method: "GET", auth: "admin" },
} as const satisfies Record<string, Route>;

type RouteName = keyof typeof routeTable;

function buildUrl(name: RouteName) {
  return routeTable[name].path;
}

// buildUrl("home") OK
// buildUrl("unknown") 컴파일 에러

여기서 as const는 각 필드 값을 리터럴로 더 강하게 고정하고, satisfiesRoute 형태를 벗어나지 못하게 합니다.

“추론이 깨지는 진짜 이유”를 보여주는 비교: as 캐스팅 vs satisfies

as는 강력하지만, 타입 시스템에게 “내가 맞으니 믿어”라고 말하는 것입니다. 그래서 잘못된 구조도 통과시킬 수 있습니다.

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

// 위험: 잘못된 method를 넣어도 강제로 통과시킬 수 있음
const bad = {
  home: { path: "/", method: "PUT" },
} as Record<string, Route>;

반면 satisfies는 검증이 목적이라서 틀리면 에러가 납니다.

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

const good = {
  home: { path: "/", method: "PUT" },
} satisfies Record<string, Route>;
// 컴파일 에러: "PUT"은 "GET" | "POST"에 할당 불가

정리하면:

  • as: 타입 단언(검사를 약화/우회), 추론도 원하는 타입으로 “덮어쓰기”
  • satisfies: 타입 검증(검사를 강화), 추론은 “원래 값 기반”으로 유지

추론 깨짐을 잡는 관점에서는 as보다 satisfies가 기본 선택지가 되는 경우가 많습니다.

제네릭 헬퍼와 satisfies: API 표면을 더 깔끔하게

객체를 여러 개 만들거나, 패턴이 반복되면 헬퍼를 만들고 싶어집니다. 이때도 satisfies를 섞으면 “검증 + 추론”을 동시에 유지할 수 있습니다.

예: 권한 정책 테이블

type Role = "guest" | "user" | "admin";

type Policy = {
  minRole: Role;
  audit: boolean;
};

function definePolicies<T extends Record<string, Policy>>(t: T) {
  return t;
}

const policies = definePolicies({
  viewHome: { minRole: "guest", audit: false },
  viewProfile: { minRole: "user", audit: false },
  deleteUser: { minRole: "admin", audit: true },
} satisfies Record<string, Policy>);

type PolicyName = keyof typeof policies;

여기서 definePolicies는 단순히 타입 추론을 “보존”하는 역할이고, satisfies는 구조 검증을 담당합니다. 패턴이 커질수록 이 분리가 유지보수에 유리합니다.

실전 체크리스트: satisfies를 어디에 붙여야 하나

다음 상황이면 satisfies를 우선 고려하는 게 좋습니다.

  1. 객체 리터럴을 타입으로 고정하고 싶지만 리터럴 추론은 유지하고 싶을 때
    • 라우트/이벤트/커맨드/설정 테이블
  2. 키 유니온(keyof typeof)을 뽑아 입력을 제한하는 API를 만들 때
    • getConfig(name) 같은 함수
  3. Record<string, ...>를 쓰고 싶은데 키가 string으로 붕괴하면 안 될 때
  4. as를 남발하고 있는데, 사실은 “검증”이 필요했던 경우

반대로 아래 상황에서는 satisfies만으로는 부족할 수 있습니다.

  • 런타임 입력(JSON, API 응답)을 검증해야 하는 경우: zod, valibot 같은 런타임 스키마가 필요
  • 값 변환/정규화가 필요한 경우: 타입만 맞춰서는 안전하지 않음

Next.js/서버 컴포넌트 맥락에서의 팁: “설정/캐시 키” 추론을 살려라

Next.js 프로젝트에서는 캐시 키, 태그, 라우트 세그먼트 이름이 문자열로 퍼지기 쉽습니다. 이때 satisfies로 키를 리터럴 유니온으로 유지해두면, 캐시 무효화/태그 기반 재검증 같은 코드에서 오타를 초기에 막을 수 있습니다.

  • 데이터가 안 바뀌는 캐시 이슈를 추적할 때도 “태그 이름이 string이라서 아무거나 들어가는 문제”가 자주 섞입니다.
  • 관련해서는 Next.js 14 RSC 캐시로 데이터가 안 바뀔 때 글이 디버깅 관점에서 도움이 됩니다.

마이그레이션 가이드: 기존 코드에서 안전하게 바꾸는 순서

  1. Record<string, X> 또는 : SomeType로 선언된 “테이블 객체”를 찾습니다.
  2. 해당 선언을 제거하고, 객체 리터럴 뒤에 satisfies ...를 붙입니다.
  3. 필요하면 as const를 추가해 리터럴을 더 강하게 고정합니다.
  4. keyof typeof table을 노출하는 API(예: type Name = keyof typeof table)를 만들고, 외부 입력을 이 타입으로 제한합니다.
  5. as SomeType 단언이 남아 있다면, 정말로 단언이 필요한지 재검토합니다. 대부분은 satisfies로 대체 가능합니다.

결론

TS 5.6+에서 satisfies는 “타입을 강하게 만들수록 추론이 약해지는” 역설을 완화하는 가장 실용적인 도구입니다. 특히 객체 리터럴 기반의 테이블 설계(라우트/이벤트/설정/정책)에서 다음을 동시에 달성합니다.

  • 구조가 틀리면 컴파일 타임에 즉시 실패
  • 키/값 리터럴 추론을 유지해 자동완성과 타입 좁히기가 강해짐
  • Record<string, ...>의 편의성은 가져가되, keyof 붕괴를 피함

프로젝트에서 문자열 테이블이 많고, 오타/누락/추론 붕괴로 인한 버그가 반복된다면 satisfies를 “기본 패턴”으로 격상시키는 것만으로도 타입 안정성이 체감되게 좋아집니다.