- Published on
TS 5.5+ satisfies로 타입 좁히기 오류 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript로 API 라우팅 테이블, 이벤트 핸들러 맵, 권한 정책(Policy) 같은 “키-값 객체”를 만들다 보면, 타입을 엄격히 걸고 싶어서 as SomeType 또는 명시적 타입 어노테이션을 붙이게 됩니다. 그런데 이 순간부터 흔히 두 가지 문제가 터집니다.
- 과도한 타입 고정: 객체 전체가
SomeType으로 “확정”되면서, 리터럴 타입 정보가 소실되거나(=widen) 반대로 너무 좁게 고정되어 이후 연산에서 유연성이 떨어집니다. - 타입 좁히기(narrowing) 실패: 유니언 분기나 키 기반 접근에서 “분명히 안전한데” 컴파일러가 따라오지 못해 에러가 납니다.
TypeScript 4.9에서 도입되고, 5.x에서 더 널리 쓰이게 된 satisfies는 이 문제를 해결하는 핵심 도구입니다. 요지는 간단합니다.
satisfies는 값의 형태가 특정 타입을 만족하는지 검증하되- 값 자체의 추론 타입은 바꾸지 않습니다.
이 글에서는 TypeScript 5.5+ 환경에서 특히 자주 마주치는 “타입 좁히기 오류”를 satisfies로 어떻게 해결하는지, 실전 패턴 중심으로 정리합니다.
> 운영 환경에서의 “진단/검증”이 중요하다는 점은 인프라 트러블슈팅과도 닮았습니다. 예를 들어 쿠버네티스에서 증상은 같아도 원인이 여러 갈래인 것처럼, 타입 에러도 원인이 widen인지, 인덱스 접근인지, 제네릭 분산 조건부인지에 따라 처방이 달라집니다. (참고: Kubernetes apiserver i/o timeout 원인과 해결)
satisfies가 해결하는 대표 증상
1) 객체를 타입 어노테이션으로 고정했더니 리터럴이 사라짐
아래 코드는 흔한 패턴입니다. 이벤트 이름과 페이로드 타입을 매핑하고 싶습니다.
type EventPayloads = {
userCreated: { id: string; email: string };
userDeleted: { id: string };
};
const payloads: EventPayloads = {
userCreated: { id: "1", email: "a@b.com" },
userDeleted: { id: "1" },
};
여기까지는 좋아 보이지만, 실제로는 “리터럴 기반의 정밀한 추론”이 필요한 순간이 옵니다. 예를 들어 키 목록을 뽑아 라우팅하거나, 특정 키만 허용하는 API를 만들 때입니다.
const keys = Object.keys(payloads);
// string[] 로 widen됨
payloads가 EventPayloads로 고정되면, Object.keys는 구조적으로 string[]를 반환하고, 이후 코드에서 keyof EventPayloads로 좁히는 과정이 번거로워집니다.
satisfies를 쓰면 “검증은 하되, 값의 추론은 유지”할 수 있습니다.
type EventPayloads = {
userCreated: { id: string; email: string };
userDeleted: { id: string };
};
const payloads = {
userCreated: { id: "1", email: "a@b.com" },
userDeleted: { id: "1" },
} satisfies EventPayloads;
// payloads의 추론 타입은 리터럴 구조를 유지하면서
// 동시에 EventPayloads를 만족하는지 검사됨
핵심은 payloads가 EventPayloads로 “캐스팅”된 것이 아니라, EventPayloads를 만족하는지 체크만 받았다는 점입니다.
2) as 캐스팅이 타입 좁히기를 망가뜨리는 경우
as는 “나는 안다”라고 컴파일러를 설득하는 도구입니다. 하지만 그 대가로 컴파일러가 제공하던 안전장치(특히 분기 좁히기)가 약해질 수 있습니다.
예를 들어 라우트 정의를 Record<string, Handler>로 캐스팅해버리면, 키가 구체적으로 무엇인지 사라져서 이후에 안전한 접근이 어려워집니다.
type Handler = (req: { path: string }) => string;
const routes = {
"/health": (req) => "ok",
"/users": (req) => "users",
} as Record<string, Handler>;
// 이제 routes["/health"]가 존재한다는 사실도
// keys가 "/health" | "/users"라는 사실도 사라짐
satisfies로 바꾸면, 핸들러 시그니처 검증은 유지하면서 키 유니언은 보존됩니다.
type Handler = (req: { path: string }) => string;
type RouteTable = Record<string, Handler>;
const routes = {
"/health": (req: { path: string }) => "ok",
"/users": (req: { path: string }) => "users",
} satisfies RouteTable;
type RoutePath = keyof typeof routes;
// "/health" | "/users"
패턴 1: “키 유니언”을 보존하면서 스키마 검증하기
현업에서 가장 많이 쓰는 케이스는 설정/정책/매핑 객체입니다. 예를 들어 권한 정책을 정의해봅시다.
type Role = "admin" | "member";
type Policy = {
[K in Role]: {
canRead: boolean;
canWrite: boolean;
};
};
const policy = {
admin: { canRead: true, canWrite: true },
member: { canRead: true, canWrite: false },
} satisfies Policy;
function canWrite(role: keyof typeof policy) {
return policy[role].canWrite;
}
여기서 policy: Policy = ...로 선언했을 때도 동작은 하지만, satisfies를 쓰면 다음 이점이 생깁니다.
- 리터럴 구조가 유지되어,
keyof typeof policy가 정확해짐 - 정책 객체에 오타 키가 들어가면 컴파일 타임에 잡힘
- 각 값의 프로퍼티 누락/타입 불일치도 잡힘
이 패턴은 “설정이 맞는지 검증하되, 실제 코드에서는 최대한 구체 타입으로 다루고 싶다”는 요구에 정확히 들어맞습니다.
패턴 2: Discriminated Union에서 분기 좁히기 실패를 줄이기
유니언 타입을 다룰 때, 객체 리터럴을 “어떤 타입으로 선언하느냐”에 따라 분기 좁히기 성공/실패가 갈립니다.
예시로 액션(Action) 유니언을 보겠습니다.
type Action =
| { type: "add"; value: number }
| { type: "remove"; id: string };
function reducer(action: Action) {
if (action.type === "add") {
return action.value + 1;
}
return action.id;
}
문제는 액션 생성기/테이블을 만들 때 생깁니다.
const actions: Record<string, Action> = {
addOne: { type: "add", value: 1 },
removeA: { type: "remove", id: "a" },
};
이 자체는 맞지만, actions.addOne.type 같은 값이 필요할 때 Record<string, Action>로 고정되면서 키 정보가 사라지고, 특정 키에 대한 구체 액션 타입으로의 좁히기가 어려워집니다.
satisfies를 적용하면 “각 값이 Action임”은 보장하면서도, 키 기반 접근에서 더 나은 추론을 얻습니다.
type Action =
| { type: "add"; value: number }
| { type: "remove"; id: string };
const actions = {
addOne: { type: "add", value: 1 },
removeA: { type: "remove", id: "a" },
} satisfies Record<string, Action>;
// keyof 보존
type ActionName = keyof typeof actions; // "addOne" | "removeA"
function run(name: ActionName) {
const action = actions[name];
// action은 여전히 Action 유니언이지만,
// name이 구체 리터럴이면 더 좁아질 여지가 생김
return action.type;
}
여기서 한 단계 더 가면, 키와 값이 강하게 연결된 테이블도 만들 수 있습니다(아래 패턴 3).
패턴 3: “키-값 상관관계”를 유지해 좁히기까지 자동화
많은 타입 에러는 사실 “인덱스로 꺼낸 값이 유니언이라서” 발생합니다. 이때 원하는 건 name이 "addOne"이면 값도 {type:"add"...}로 좁혀지는 상관관계입니다.
이를 위해 테이블을 as const로 고정하고, satisfies로 스키마만 검증하는 조합이 강력합니다.
type Action =
| { type: "add"; value: number }
| { type: "remove"; id: string };
const actions = {
addOne: { type: "add", value: 1 },
removeA: { type: "remove", id: "a" },
} as const satisfies Record<string, Action>;
type ActionsMap = typeof actions;
type ActionOf<K extends keyof ActionsMap> = ActionsMap[K];
function run<K extends keyof ActionsMap>(name: K) {
const action: ActionOf<K> = actions[name];
if (action.type === "add") {
// 여기서 action은 { type: "add"; value: 1 }
return action.value + 1;
}
// 여기서 action은 { type: "remove"; id: "a" }
return action.id;
}
as const는 값 리터럴을 최대한 좁게 고정합니다.satisfies는 그 값이Record<string, Action>규격을 만족하는지만 확인합니다.
이 조합은 “테이블 기반 분기”에서 타입 좁히기 오류를 크게 줄여줍니다.
패턴 4: satisfies로 “과잉 속성(excess property)”을 정확히 잡기
객체 리터럴은 할당 시점에 “과잉 속성 검사”가 일어나는데, as를 쓰면 이 검사가 무력화되기 쉽습니다.
type User = { id: string; email: string };
// 위험: 과잉 속성도 통과시킬 수 있음
const u1 = { id: "1", email: "a@b.com", admin: true } as User;
satisfies는 캐스팅이 아니라 검증이므로, 이런 실수를 잡습니다.
type User = { id: string; email: string };
const u2 = {
id: "1",
email: "a@b.com",
admin: true,
} satisfies User;
// 에러: 'admin'은 User에 없음
설정 파일/환경 변수 매핑/피처 플래그 같은 곳에서 특히 유용합니다. “문법적으로는 맞는데 운영에서 터지는” 종류의 실수를 컴파일 타임으로 끌어오는 효과가 있습니다. 운영 장애를 줄이기 위한 체크리스트를 미리 만드는 것과 유사한 접근입니다(참고: EKS Ingress 503인데 Pod 정상일 때 점검 가이드).
TypeScript 5.5+에서 체감이 커지는 이유
TypeScript는 버전이 올라가며 객체 리터럴, 제네릭, 컨트롤 플로우 분석이 지속적으로 개선됩니다. 5.5+에서는 특히 “추론 결과를 최대한 활용하는 코드”가 더 이득을 봅니다.
satisfies는 추론을 포기하지 않고 타입 안전성을 올리는 방식이라, 최신 TS의 개선을 그대로 흡수합니다.- 반대로
as SomeType남발은 추론/분석 개선의 혜택을 스스로 차단합니다.
즉, TS가 똑똑해질수록 satisfies는 더 좋은 기본값이 됩니다.
자주 하는 실수와 체크포인트
1) satisfies는 “타입을 바꾸지 않는다”
아래는 기대와 다른 동작으로 헷갈리기 쉬운 예입니다.
type Shape = { kind: "circle"; r: number } | { kind: "square"; w: number };
const s = { kind: "circle", r: 10 } satisfies Shape;
// s는 Shape가 아니라, { kind: "circle"; r: number }로 추론됨
이건 단점이 아니라 장점입니다. 다만 “변수를 유니언으로 다루고 싶다”면 별도로 어노테이션을 해야 합니다.
const s2: Shape = { kind: "circle", r: 10 };
정리하면:
- 검증 + 구체 추론 유지:
const x = {...} satisfies T - 그 변수 자체를 T로 취급:
const x: T = {...}
2) 인덱스 접근에서 여전히 string 문제가 남는 경우
Object.keys()는 기본적으로 string[]를 반환하므로, 아래는 여전히 타입 캐스팅/헬퍼가 필요합니다.
const keys = Object.keys(actions); // string[]
이때는 안전한 헬퍼를 만들어 해결합니다.
function typedKeys<T extends object>(obj: T) {
return Object.keys(obj) as Array<keyof T>;
}
const keys = typedKeys(actions); // ("addOne" | "removeA")[]
satisfies는 “객체 정의 시점의 검증/추론”을 돕는 도구이고, 런타임 내장 함수의 타입 한계를 자동으로 바꾸진 않습니다.
결론: as 대신 satisfies를 기본으로 두기
TypeScript 5.5+에서 satisfies는 다음 상황에서 특히 강력합니다.
- 라우팅 테이블/핸들러 맵/정책/설정처럼 객체 리터럴을 선언할 때
Record<...>나 매핑 타입을 만족해야 하지만, 키 유니언과 리터럴 정보를 유지하고 싶을 때as로 인해 과잉 속성 검사나 타입 좁히기가 깨지는 문제를 줄이고 싶을 때
실무 기준의 추천 규칙은 간단합니다.
- “검증만” 필요하면
satisfies - 정말로 그 변수 타입을 바꿔야 할 때만
: T또는 제한적으로as - 테이블 기반 상관관계가 필요하면
as const satisfies ...조합
타입 시스템은 결국 운영 안정성과 개발 속도를 동시에 올리는 장치입니다. 타입 에러를 억지로 눌러버리기보다(as), 원인을 구조적으로 해결하는(satisfies) 쪽이 장기적으로 비용이 적게 듭니다.