- Published on
TypeScript 5.5 infer 오류, satisfies로 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 타입 레벨 유틸리티를 조합해 잘 돌아가던 코드가 TypeScript 5.5로 올린 뒤 갑자기 타입 에러를 뿜는 경우가 있습니다. 특히 infer 기반의 조건부 타입과 객체 리터럴 추론이 맞물릴 때, 추론 결과가 기대보다 넓어지거나(리터럴이 string으로 확장) 반대로 너무 좁아져(불필요한 never) 연쇄적으로 깨지는 일이 생깁니다.
이 글에서는 “TypeScript 5.5에서 infer가 깨진 것처럼 보이는” 대표적인 상황을 재현하고, 왜 이런 현상이 생기는지, 그리고 실무에서 가장 깔끔하게 고칠 수 있는 도구로 satisfies를 어떻게 쓰면 좋은지 정리합니다.
문제: TS 5.5 업그레이드 후 infer 기반 타입이 흔들리는 이유
TypeScript의 타입 시스템은 버전이 올라가면서 추론 전략과 리터럴 확장 규칙, 조건부 타입 평가 순서 등에서 미묘한 변화가 들어갑니다. 대부분은 더 정확한 타입을 주기 위한 개선이지만, 다음 조합에서는 “내가 기대한 타입”과 “컴파일러가 추론한 타입”이 어긋나기 쉽습니다.
- 객체 리터럴을 그대로 넘기거나 반환할 때 리터럴 타입이 넓어짐
- 조건부 타입에서
infer로 뽑아낸 타입이unknown또는never로 흐름이 바뀜 - 제네릭 함수에 객체 리터럴을 전달할 때, 타입 파라미터가 예상과 다르게 결정됨
특히 as const를 남발하거나, 반대로 “타입 주석을 달아서” 추론을 망가뜨리는 패턴이 섞이면, TS 5.5에서 노출되는 타입 불일치가 눈에 띄게 늘어납니다.
재현: infer로 키를 뽑는 라우트 정의가 무너지는 케이스
아래는 흔한 패턴입니다. 라우트 정의 객체에서 path를 추출해 유니온 타입을 만들고, navigate 같은 함수가 그 유니온만 받게 하는 방식입니다.
// 라우트 정의에서 path 유니온을 뽑고 싶다
type RouteDef = {
name: string;
path: string;
};
type PathsOf<T> = T extends Record<string, infer R>
? R extends { path: infer P }
? P
: never
: never;
function makeNavigator<T extends Record<string, RouteDef>>(routes: T) {
type Paths = PathsOf<T>;
return {
navigate(path: Paths) {
return path;
},
};
}
const nav = makeNavigator({
home: { name: "Home", path: "/" },
user: { name: "User", path: "/users" },
});
nav.navigate("/");
nav.navigate("/users");
// 기대: 아래는 에러
nav.navigate("/admin");
여기서 핵심은 Paths가 "/" | "/users"로 추론되길 기대한다는 점입니다. 그런데 실제로는 리터럴이 확장되어 string이 되어버리면 nav.navigate("/admin")도 통과해버립니다.
왜냐하면 routes의 각 path가 객체 리터럴임에도 불구하고, 제네릭 경계 T extends Record<string, RouteDef> 때문에 내부가 RouteDef로 “맞춰지면서” path: string으로 넓어질 수 있기 때문입니다. 이 현상은 TS 버전 변화에 따라 더 잘 드러나거나, 기존에 우연히 통과하던 코드가 깨지는 형태로 나타납니다.
흔한 임시처방: 타입 주석 또는 as const의 부작용
1) 타입 주석으로 고정하면 리터럴이 죽는다
const routes: Record<string, RouteDef> = {
home: { name: "Home", path: "/" },
user: { name: "User", path: "/users" },
};
이렇게 쓰면 path는 무조건 string이 됩니다. 즉, 리터럴 유니온을 만들 기회 자체가 사라집니다.
2) as const는 너무 강하다
const routes = {
home: { name: "Home", path: "/" },
user: { name: "User", path: "/users" },
} as const;
이러면 path 리터럴은 보존되지만, 모든 필드가 readonly가 되고 값 타입이 과도하게 좁아집니다. 이후에 라우트 정의를 합치거나, 일부 값을 계산으로 만들거나, 라이브러리 타입과 맞추는 과정에서 또 다른 충돌을 만들기 쉽습니다.
정답에 가까운 해결: satisfies로 “검증만” 하고 추론은 살린다
satisfies는 “이 값이 특정 타입을 만족하는지 체크”하되, 값 자체의 추론 타입을 그 타입으로 강제하지 않습니다. 즉,
- 타입 안정성(필드 누락, 오타, 타입 불일치)은 잡고
- 리터럴 추론(예:
"/users")은 유지합니다
routes 정의에 satisfies 적용
type RouteDef = {
name: string;
path: string;
};
const routes = {
home: { name: "Home", path: "/" },
user: { name: "User", path: "/users" },
// admin: { name: "Admin" }, // path 누락 시 여기서 바로 에러
} satisfies Record<string, RouteDef>;
type Paths = typeof routes[keyof typeof routes]["path"];
// Paths는 "/" | "/users"
function navigate(path: Paths) {
return path;
}
navigate("/");
// @ts-expect-error
navigate("/admin");
이 패턴의 장점은 infer를 굳이 복잡하게 쓰지 않아도 된다는 점입니다. 물론 더 복잡한 메타프로그래밍에서도 satisfies는 유용하지만, 많은 케이스에서 typeof 인덱싱만으로도 충분히 강력합니다.
infer를 계속 써야 한다면: satisfies로 입력 타입을 “정돈”하라
실무에서는 infer가 필요한 경우가 많습니다. 예를 들어 플러그인/핸들러 맵에서 입력과 출력 타입을 뽑아내거나, 스키마 정의에서 결과 타입을 계산하는 경우입니다.
아래는 핸들러 맵에서 각 핸들러의 input을 추출해 라우팅 함수 시그니처를 만드는 예시입니다.
type Handler<I, O> = (input: I) => O;
type InputOf<T> = T extends Handler<infer I, any> ? I : never;
type HandlersMap = Record<string, Handler<any, any>>;
type InputsOfMap<T extends HandlersMap> = {
[K in keyof T]: InputOf<T[K]>;
};
const handlers = {
createUser: (input: { name: string; age: number }) => ({ id: "1" }),
deleteUser: (input: { id: string }) => ({ ok: true }),
} satisfies HandlersMap;
type Inputs = InputsOfMap<typeof handlers>;
// {
// createUser: { name: string; age: number };
// deleteUser: { id: string };
// }
function call<K extends keyof typeof handlers>(
name: K,
input: Inputs[K]
) {
return handlers[name](input);
}
call("createUser", { name: "A", age: 20 });
// @ts-expect-error
call("createUser", { name: "A" });
여기서 satisfies HandlersMap이 없고, 대신 const handlers: HandlersMap = ...로 타입 주석을 달면 각 핸들러의 구체적인 시그니처가 Handler<any, any>로 지워져 infer가 any를 뽑아버리는 문제가 생깁니다. satisfies는 이 문제를 피하면서도 “핸들러 맵 형태가 맞는지”는 검증해줍니다.
TS 5.5에서 특히 자주 보이는 증상 체크리스트
다음 중 하나라도 해당하면 satisfies 적용을 우선 검토하는 게 좋습니다.
- 객체 리터럴을
Record<string, X>같은 타입 주석으로 받는 순간 리터럴 유니온이 사라짐 - 조건부 타입
infer결과가 갑자기never또는unknown으로 바뀌어 연쇄 에러 발생 - 제네릭 함수 인자로 넘긴 객체의 구체 타입이 보존되지 않아, 후속 타입 계산이 전부
string또는any가 됨
해결 접근은 보통 다음 순서가 효율적입니다.
- 타입 주석(
:)으로 “값의 타입을 강제”하고 있는 지점을 찾는다 - 강제가 필요 없다면
satisfies로 “형태만 검증”하게 바꾼다 - 리터럴 보존이 과도하게 필요하면 그때만
as const를 국소적으로 적용한다
실전 패턴 3가지
1) 설정 객체(Config) 검증 + 리터럴 보존
type Config = {
mode: "dev" | "prod";
logLevel: "debug" | "info" | "warn" | "error";
};
const config = {
mode: "prod",
logLevel: "info",
} satisfies Config;
// config.mode는 "prod" 리터럴로 유지될 수 있음
2) 이벤트 맵에서 payload 추출
type EventMap = Record<string, { payload: unknown }>;
type PayloadOf<T> = T extends { payload: infer P } ? P : never;
type Payloads<T extends EventMap> = {
[K in keyof T]: PayloadOf<T[K]>;
};
const events = {
userCreated: { payload: { id: "1", name: "A" } },
userDeleted: { payload: { id: "1" } },
} satisfies EventMap;
type EP = Payloads<typeof events>;
3) 라우트 정의에서 path 유니온 만들기(가장 흔함)
type RouteDef = { name: string; path: string };
const routes = {
home: { name: "Home", path: "/" },
users: { name: "Users", path: "/users" },
} satisfies Record<string, RouteDef>;
type RoutePath = typeof routes[keyof typeof routes]["path"];
function navigate(path: RoutePath) {}
언제 satisfies가 만능이 아닌가
- 값 자체를 특정 타입으로 “캐스팅”해서 API 경계 타입을 맞춰야 하는 경우(예: 외부 라이브러리 함수가 정확히 그 타입을 요구)
satisfies로 검증할 타입이 너무 넓어서(예:Record<string, any>) 실질적 검증 효과가 없는 경우
이럴 때는 satisfies 대상 타입을 더 구체화하거나, 검증 레이어(스키마 검증, 런타임 파서)를 추가하는 것이 맞습니다.
디버깅 팁: 타입이 넓어지는 지점을 찾는 법
타입 추론이 의도대로 안 될 때는 “어디서 리터럴이 string으로 확장됐는지”를 찾는 게 핵심입니다.
- 객체에 타입 주석을 달았는지 확인:
const x: SomeType = ... - 제네릭 제약이 너무 강한지 확인:
T extends RouteDef같은 경계가 실제 값을 RouteDef로 맞추며 리터럴을 죽일 수 있음 - 중간 변수에 담는 순간 타입이 바뀌는지 확인
그리고 가능한 경우, 아래처럼 중간 타입을 강제로 확인해보면 추론이 깨지는 지점을 빠르게 찾을 수 있습니다.
const _debugPath: typeof routes[keyof typeof routes]["path"] = "/";
이 줄이 string으로 보이면 이미 리터럴이 깨진 것입니다.
마무리: infer가 문제가 아니라 “입력 타입이 망가진 것”일 때가 많다
TypeScript 5.5에서 갑자기 infer가 오동작하는 것처럼 보이는 케이스 상당수는, 실제로는 infer가 실패한 게 아니라 infer에 들어가는 입력 타입이 이미 넓어져 버린 경우입니다. 이때 satisfies는 타입 검증과 추론을 분리해 주기 때문에, 가장 작은 수정으로 타입 안정성과 개발 경험을 동시에 회복할 수 있습니다.
추론이 깨지는 문제는 CI에서만 터지거나(버전 차이), 특정 파일에서만 재현되는 경우도 많습니다. 이런 종류의 “환경/버전 차이로 생기는 미묘한 오류”를 추적하는 과정은 캐시나 빌드 파이프라인 문제를 디버깅하는 것과 닮아 있습니다. 필요하다면 GitHub Actions 캐시가 안 먹을 때 디버깅 체크리스트도 함께 참고하면 원인 분리에 도움이 됩니다.
또한 Node 런타임/모듈 시스템 변화가 빌드 결과에 영향을 주는 프로젝트라면, 타입 문제와 별개로 모듈 로딩 이슈가 섞여 보일 수 있으니 Node.js ESM+CJS 혼용 시 ERR_REQUIRE_ESM 해결법도 같이 점검해두면 좋습니다.