- Published on
TS 5.6 satisfies로 타입추론 깨짐 해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 오래 쓰다 보면 “타입을 엄격하게 걸었더니 오히려 타입추론이 죽는” 순간을 자주 만납니다. 대표적으로 객체 리터럴을 : SomeType로 주석 달아 버리면 리터럴 타입이 넓어지고("GET" → string), 키가 일반화되며("/users" → string), 이후 제네릭/유니온 좁히기에서 연쇄적으로 손해를 봅니다.
TS 4.9에서 도입된 satisfies는 이 문제를 정면으로 해결합니다. 핵심은 다음 한 줄입니다.
satisfies는 “이 값이 특정 타입 조건을 만족하는지”만 검사하고- “값 자체의 타입(리터럴/좁은 타입)”은 보존합니다.
TS 5.6에서는 이 패턴이 더 널리 쓰이면서, 설정/레지스트리/라우팅/상태머신/스키마 같은 “객체 리터럴 기반 설계”에서 타입추론을 지키는 사실상 표준 도구가 됐습니다. 아래는 실무에서 타입추론 깨짐을 복구하는 7가지 패턴입니다.
> 디버깅 관점에서 보면, 앱에서 타입이 꼬여 원인을 추적하는 과정은 인프라 장애를 로그로 좁혀가는 것과 비슷합니다. 예를 들어 Next.js에서 hydration 경고를 빠르게 분리하는 접근처럼(Next.js 14 Hydration failed 경고 10분 해결법), 타입도 “어디서 widen 되었는지”를 먼저 찾는 게 중요합니다.
1) : Type 주석 대신 satisfies로 리터럴 타입 보존
가장 흔한 실수는 객체 리터럴에 타입 주석을 달아 버리는 것입니다.
문제: 타입 주석이 리터럴을 widen
type Route = {
method: "GET" | "POST";
path: `/${string}`;
};
// ❌ 타입 주석을 달면 값의 구체성이 사라질 수 있음
const r1: Route = {
method: "GET",
path: "/users",
};
// r1.method는 "GET"이 아니라 "GET" | "POST"로,
// r1.path는 "/users"가 아니라 `/${string}`로 넓어짐
해결: satisfies로 검증만 하고 리터럴 유지
const r2 = {
method: "GET",
path: "/users",
} satisfies Route;
// r2.method: "GET"
// r2.path: "/users"
이 차이가 이후 switch/매핑/제네릭 추론에서 엄청난 파급을 만듭니다.
2) 설정 객체(Config)에서 키/값 추론을 살리고 검증만 추가
환경설정/피처 플래그/요금제 테이블 같은 “큰 객체”는 타입추론이 깨지기 쉬운 영역입니다.
문제: Record<string, ...>로 덮어쓰면 키가 전부 string
type FeatureFlags = Record<string, { enabled: boolean; rollout: number }>;
const flags: FeatureFlags = {
newCheckout: { enabled: true, rollout: 30 },
darkMode: { enabled: false, rollout: 0 },
};
// keyof typeof flags 가 string이 되어버려 키 기반 로직이 약해짐
해결: satisfies로 스키마만 강제하고 키는 리터럴로 유지
type FeatureFlag = { enabled: boolean; rollout: number };
type FeatureFlagsShape = Record<string, FeatureFlag>;
const flags = {
newCheckout: { enabled: true, rollout: 30 },
darkMode: { enabled: false, rollout: 0 },
} satisfies FeatureFlagsShape;
type FlagName = keyof typeof flags;
// "newCheckout" | "darkMode"
이 패턴은 “검증은 엄격하게, 추론은 풍부하게”라는 satisfies의 정석입니다.
3) 유니온 기반 상태/액션 매핑에서 never 누락을 타입으로 잡기
상태머신이나 reducer에서 “모든 케이스를 처리했는지” 확인하려고 객체 매핑을 쓰는 경우가 많습니다.
목표
- 키는 유니온을 완전 커버해야 함
- 값은 각 키에 맞는 정확한 핸들러 시그니처를 가져야 함
- 동시에 구현은 객체 리터럴의 리터럴 추론을 유지하고 싶음
type Status = "idle" | "loading" | "success" | "error";
type Handlers = {
[K in Status]: (ctx: { status: K }) => string;
};
const handlers = {
idle: (ctx) => `now: ${ctx.status}`,
loading: (ctx) => `now: ${ctx.status}`,
success: (ctx) => `now: ${ctx.status}`,
error: (ctx) => `now: ${ctx.status}`,
} satisfies Handlers;
// 만약 loading을 빼면 컴파일 에러로 누락을 즉시 확인
여기서 : Handlers를 쓰면 구현체가 강제로 넓어지면서(특히 함수 파라미터) IDE에서 추론이 둔해지는 경우가 있는데, satisfies는 “검사만” 수행해 개발 경험이 좋아집니다.
4) 제네릭 함수에 넘길 “스키마 객체”를 안전하게 만들기
제네릭 API(예: 라우터/쿼리 빌더/검증기)에 스키마를 넘길 때, 타입 주석으로 값을 덮으면 제네릭이 잃는 정보가 많습니다.
예시: 라우트 테이블에서 path 리터럴을 유지해야 하는 케이스
type Endpoint = {
method: "GET" | "POST";
path: `/${string}`;
};
type EndpointTable = Record<string, Endpoint>;
function buildClient<T extends EndpointTable>(table: T) {
return {
call<K extends keyof T>(key: K) {
return table[key].path;
},
};
}
const api = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
} satisfies EndpointTable;
const client = buildClient(api);
const p = client.call("listUsers");
// p 타입이 "/users"로 유지될 가능성이 커짐
핵심은 api가 EndpointTable “이상”의 정보를 갖고 있어야 제네릭 T가 풍부하게 추론된다는 점입니다.
5) as const 남발 대신 satisfies로 “필요한 만큼만” 고정
as const는 강력하지만, 모든 값을 readonly/리터럴로 얼려버려 오히려 쓰기 불편해지는 경우가 있습니다(특히 배열 조작, 함수 인자 호환성).
문제: as const는 과하게 얼림
const roles = ["admin", "user"] as const;
// readonly ["admin", "user"]
해결: 리터럴 유니온만 필요하면 satisfies로 최소 고정
type Role = "admin" | "user";
const roles = ["admin", "user"] satisfies Role[];
// roles: ("admin" | "user")[] (readonly 아님)
이 방식은 “값은 가변 배열로 쓰되, 원소는 Role만” 같은 현실적인 제약을 표현하기 좋습니다.
6) Record<Union, ...>에서 값 타입의 ‘연관성(relationship)’ 유지
유니온 키에 따라 값 구조가 달라지는 “연관 타입”은 타입추론이 자주 깨집니다.
예시: 이벤트 타입에 따라 payload가 달라짐
type Event =
| { type: "click"; payload: { x: number; y: number } }
| { type: "submit"; payload: { formId: string } };
type EventType = Event["type"];
type EventPayloadMap = {
[K in EventType]: Extract<Event, { type: K }>["payload"];
};
const payloadExamples = {
click: { x: 1, y: 2 },
submit: { formId: "signup" },
} satisfies EventPayloadMap;
// click에 formId를 넣는 순간 컴파일 에러
이 패턴은 이벤트 버스, 메시지 큐, gRPC/REST DTO 매핑 등에서 특히 유용합니다. (타임아웃/데드라인 전파처럼 “연관성이 깨지면 장애로 이어지는” 영역은 더더욱 타입으로 잠가야 합니다: gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결)
7) 플러그인/레지스트리 패턴에서 “등록은 엄격, 사용은 추론” 만들기
플러그인 레지스트리는 보통 아래 두 요구를 동시에 가집니다.
- 등록 시점: 스펙을 지키지 않으면 실패해야 함
- 사용 시점: 등록된 키/옵션이 자동완성으로 좁혀져야 함
예시: 플러그인 등록 테이블
type PluginSpec = {
setup: (opts: unknown) => void;
};
type PluginRegistryShape = Record<string, PluginSpec>;
const plugins = {
logger: {
setup: (opts: { level: "debug" | "info" }) => {
console.log("level", opts.level);
},
},
metrics: {
setup: (opts: { endpoint: string; sampleRate: number }) => {
console.log(opts.endpoint, opts.sampleRate);
},
},
} satisfies PluginRegistryShape;
type PluginName = keyof typeof plugins;
// "logger" | "metrics"
function initPlugin<K extends PluginName>(name: K, opts: Parameters<(typeof plugins)[K]["setup"]>[0]) {
plugins[name].setup(opts);
}
initPlugin("logger", { level: "debug" });
// initPlugin("logger", { endpoint: "..." }); // ❌ 에러
포인트는 plugins를 : PluginRegistryShape로 선언하지 않고 satisfies로 “형태만 검사”해야 typeof plugins에서 각 플러그인의 구체 타입이 살아남는다는 것입니다.
실전 체크리스트: satisfies를 어디에 붙여야 하나?
아래 중 하나라도 해당하면 : Type 대신 satisfies Type를 우선 고려하세요.
- 객체 리터럴/배열 리터럴을 “테이블처럼” 쓰고 있다(라우트, 스키마, 설정, 매핑).
- 키를
keyof typeof obj로 뽑아 쓰거나, 키 기반 제네릭을 돌린다. - 리터럴 타입이 유지되어야 하는데
string/number/boolean으로 자꾸 widen 된다. as const를 붙이니 readonly 때문에 오히려 코드가 불편해졌다.- 유니온 키/값의 연관성을 강제하고 싶다(Extract/매핑 타입).
마무리
TS 5.6에서 satisfies는 단순 문법 설탕이 아니라 “타입 안정성과 추론 품질을 동시에 가져가는 설계 도구”에 가깝습니다. 특히 객체 리터럴을 중심으로 설계하는 코드베이스(프론트 라우팅, 백엔드 핸들러 맵, 이벤트/메시지 스키마, 플러그인 레지스트리)에서는, 타입 주석으로 정보를 덮어써서 추론을 망치는 일이 잦습니다.
정리하면:
: Type는 값의 타입을 그 Type으로 고정(추론 희생)satisfies Type는 Type을 만족하는지 검사(추론 보존)
위 7가지 패턴을 팀 컨벤션으로 묶어두면, “타입은 맞는데 개발 경험이 구려지는” 문제를 크게 줄일 수 있습니다.