- Published on
TS 5.x satisfies로 타입 좁힘이 안될 때 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript 4.9부터 도입된 satisfies는 “이 값이 특정 타입의 제약을 만족하는지”를 컴파일 타임에 검증해주는 매우 유용한 연산자입니다. 특히 객체 리터럴에 대해 과잉 속성 검사(excess property check) 를 유지하면서도, 값의 “구체적인 리터럴 타입”을 최대한 보존할 수 있다는 점 때문에 설정 객체, 라우팅 테이블, 이벤트 맵 등에서 자주 사용됩니다.
그런데 실무에서 satisfies를 붙였더니 기대했던 타입 좁힘(narrowing) 이 되지 않거나, 오히려 특정 프로퍼티가 string/number처럼 넓게 추론되어 분기문에서 안전하게 다루기 어려워지는 경우가 있습니다. 이 글은 “왜 그런가?”를 TS의 타입 추론 규칙 관점에서 설명하고, 바로 적용 가능한 해결 패턴을 코드로 정리합니다.
(참고로 문제 원인이 ‘검증은 되는데 런타임에서만 터지는’ 종류라면, 장애 재현/원인 격리 패턴이 도움이 됩니다. 비슷한 접근으로 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드도 함께 보면 좋습니다.)
1) 핵심: satisfies는 타입을 “바꾸지” 않는다
가장 중요한 문장 하나만 기억하면 됩니다.
expr satisfies T는 expr의 타입을 T로 캐스팅하지 않습니다.- 단지 expr의 타입이 T에 할당 가능한지 검사하고, 결과 타입은 기본적으로 expr의 추론 타입을 유지합니다.
즉 as T와 목적이 다릅니다.
as T: 타입을 T로 “단언”해서 이후 타입 체커가 T로 취급satisfies T: 타입은 그대로 두되 “T를 만족함”을 검증
이 차이 때문에 “satisfies를 붙였으니 이제 이 값은 T로 좁혀지겠지?”라고 기대하면 어긋납니다.
2) satisfies를 썼는데 좁힘이 안 되는 대표 증상
증상 A: 유니온(discriminated union) 분기가 안 먹는다
예를 들어, kind로 분기하는 전형적인 discriminated union을 생각해봅시다.
type Command =
| { kind: "create"; payload: { name: string } }
| { kind: "delete"; payload: { id: string } };
const cmd = {
kind: "create",
payload: { name: "alice" },
} satisfies Command;
if (cmd.kind === "create") {
cmd.payload.name; // 기대: OK
// cmd.payload.id; // 기대: 에러
}
이 케이스는 대체로 잘 동작합니다. 그런데 아래처럼 객체를 한 번 더 감싸거나, 인덱싱/맵 형태로 저장하는 순간부터 문제가 생기기 쉽습니다.
증상 B: 맵/레지스트리에 넣자마자 리터럴이 넓어져 분기 불가
type Handler =
| { kind: "json"; parse: (s: string) => unknown }
| { kind: "text"; parse: (s: string) => string };
type Registry = Record<string, Handler>;
const registry = {
a: { kind: "json", parse: JSON.parse },
b: { kind: "text", parse: (s: string) => s },
} satisfies Registry;
const h = registry.a;
if (h.kind === "json") {
// 여기서 h가 "json" 핸들러로 좁혀지길 기대
h.parse("{}");
}
이 코드는 환경/구성에 따라 h가 기대만큼 좁혀지지 않거나(특히 registry[key]처럼 동적 인덱싱을 쓰면), h.kind가 "json" | "text"로 남아서 분기가 무의미해질 수 있습니다.
원인은 satisfies 자체라기보다, Record<string, Handler>라는 컨테이너 타입이 “키마다 서로 다른 변형”을 유지하지 못하고, 결과적으로 값 타입을 Handler 유니온으로 뭉개버리기 때문입니다.
증상 C: string으로 widen되어 리터럴 비교가 의미 없어짐
type Status = "idle" | "loading" | "success" | "error";
const state = {
status: "loading",
} satisfies { status: Status };
// state.status가 "loading"이 아니라 Status(혹은 string)로 잡히는 경우가 생김
여기서도 관건은 satisfies가 아니라 “리터럴 타입이 widen(확장)되는 조건”입니다. as const가 없고, 객체가 특정 맥락 타입(contextual type)으로 추론되면 리터럴이 넓어질 수 있습니다.
3) 왜 이런 일이 생기나: widen, 컨텍스추얼 타이핑, 인덱싱
TypeScript의 추론을 이해하면 해결책이 선명해집니다.
리터럴 widen
const x = "a"는 보통"a"- 하지만 어떤 맥락 타입이
string이면"a"가string으로 widen될 수 있음
컨테이너 타입이 정보 손실을 유발
Record<string, Handler>는 “각 키마다 다른 구체 타입”을 표현하지 못합니다.- 결국
registry.a도Handler(유니온)로 보게 되고, 분기 전에는 좁혀지지 않습니다.
동적 인덱싱은 더 강하게 뭉갠다
registry[key]에서key: string이면 결과는 무조건Handlerkey: "a" | "b"라도 결과는Handler유니온이 되고, 그 뒤 좁힘은 제한됩니다.
이건 satisfies의 결함이라기보다, “검증 연산자”를 “추론/모델링 도구”로 착각할 때 생기는 전형적인 함정입니다.
4) 해결 레시피 1: as const로 리터럴 고정 + satisfies로 검증
가장 많이 쓰는 조합입니다.
type Status = "idle" | "loading" | "success" | "error";
type State = {
status: Status;
retry?: number;
};
const state = {
status: "loading",
retry: 3,
} as const satisfies State;
// state.status는 "loading" (리터럴)
// 동시에 State 제약을 만족하는지 검사됨
주의할 점:
as const는 객체 전체를 readonly로 만들고 리터럴을 고정합니다.- “readonly가 싫다”면 아래 레시피 2/3을 고려하세요.
5) 해결 레시피 2: satisfies로 검증하고, 실제 사용 타입은 별도로 명시
검증과 사용 타입을 분리하는 방식입니다.
type Config = {
mode: "dev" | "prod";
endpoint: string;
};
const rawConfig = {
mode: "dev",
endpoint: "http://localhost:3000",
// typo: "endpont" 같은 실수는 satisfies가 잡아줌
} satisfies Config;
// 사용부에서는 명시적으로 Config로 취급
const config: Config = rawConfig;
if (config.mode === "dev") {
// 여기서는 확실히 좁혀짐
}
이 패턴은 “리터럴을 꼭 유지해야 하는가?”에 따라 장단이 갈립니다.
- 장점: 사용부 타입이 명확, readonly 문제 없음
- 단점: 리터럴 유지(예:
"dev"그대로)보다는Config["mode"]로 보게 됨
6) 해결 레시피 3: defineX() 헬퍼로 추론을 원하는 방향으로 유도
특히 레지스트리/맵에서 키별 타입을 살리고 싶다면, Record<string, ...>로 뭉개기보다 제네릭 헬퍼로 “그대로 추론”하게 만드는 게 좋습니다.
type Handler =
| { kind: "json"; parse: (s: string) => unknown }
| { kind: "text"; parse: (s: string) => string };
function defineRegistry<T extends Record<string, Handler>>(r: T) {
return r;
}
const registry = defineRegistry({
a: { kind: "json", parse: JSON.parse },
b: { kind: "text", parse: (s: string) => s },
} satisfies Record<string, Handler>);
const a = registry.a;
if (a.kind === "json") {
a.parse("{}");
// a는 { kind: "json"; ... }로 잘 좁혀짐
}
포인트는 다음과 같습니다.
defineRegistry의 반환 타입이T이므로, 각 프로퍼티의 구체 타입이 보존됩니다.satisfies Record<string, Handler>로 “Handler 제약을 만족하는지”는 검증합니다.
이 패턴은 라우팅 테이블, 이벤트 핸들러 맵, 에러 코드 맵 등에서 매우 강력합니다.
7) 해결 레시피 4: 동적 키 인덱싱을 피하거나, 키를 유니온으로 제한
registry[key]에서 key: string이면 어떤 마법을 써도 결과는 Handler 유니온입니다. 해결책은 키를 제한하거나, 키-값 관계를 타입으로 모델링하는 것입니다.
const registry = {
a: { kind: "json", parse: JSON.parse },
b: { kind: "text", parse: (s: string) => s },
} as const satisfies Record<string, Handler>;
type Key = keyof typeof registry; // "a" | "b"
function getHandler<K extends Key>(key: K) {
return registry[key]; // K에 따라 정확한 타입 반환
}
const h = getHandler("a");
if (h.kind === "json") {
h.parse("{}");
}
핵심은 K extends Key로 키를 좁혀 “인덱싱 결과가 키에 종속”되도록 만드는 것입니다.
8) 해결 레시피 5: satisfies 대신 as가 더 적절한 경우
다음 상황에서는 satisfies가 아니라 as(혹은 명시적 타입 선언)가 더 단순합니다.
- 런타임에서 값이 이미 신뢰 가능하고(예: 서버에서 스키마 검증 완료)
- 이후 코드에서 “이 값은 이 타입이다”로 고정해서 다루고 싶을 때
type Payload = { kind: "create"; name: string } | { kind: "delete"; id: string };
declare const fromServer: unknown;
// (예: zod로 검증했다고 가정)
const payload = fromServer as Payload;
if (payload.kind === "delete") {
payload.id; // 좁힘 OK
}
다만 as는 검증이 아니라 단언이므로, 검증이 필요하면 런타임 스키마(zod, valibot 등)와 함께 쓰는 것이 안전합니다.
9) 실전 체크리스트: “satisfies인데 좁힘이 안 돼요”를 3분 안에 진단
내가 원하는 건 ‘검증’인가 ‘타입 고정’인가?
- 고정이면
: T또는as T
- 고정이면
리터럴이 widen됐는가?
as const또는 헬퍼 함수로 추론 보존
Record/인덱스 시그니처로 뭉갰는가?
Record<string, X>는 정보 손실이 정상defineX<T extends ...>(x: T): T패턴 고려
동적 인덱싱을 하고 있는가?
keyof typeof obj+ 제네릭K extends Key로 종속 타입 만들기
분기 기준(discriminant)이 정말 리터럴인가?
kind: "json"처럼 리터럴이어야 하고,string이면 분기 불가
(복잡한 타입 이슈는 “재현 가능한 최소 예제”를 만드는 게 가장 빠릅니다. 장애/이슈를 재현해 원인을 좁히는 접근은 인프라/네트워크에서도 동일하게 유효합니다. 예를 들어 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트처럼 ‘현상 → 재현 → 원인 분리 → 해결’ 순서가 결국 가장 효율적입니다.)
결론
TS 5.x에서 satisfies는 타입 시스템을 더 “정확하게” 쓰게 해주는 도구지만, 역할은 어디까지나 제약 검증입니다. 좁힘이 안 되는 순간은 대부분 satisfies 자체가 아니라 다음 중 하나입니다.
- 리터럴이 widen됨
Record<string, ...>같은 컨테이너가 타입 정보를 뭉갬- 동적 인덱싱으로 유니온이 유지됨
해결은 의외로 단순한 편입니다.
- 리터럴 고정이 필요하면
as const satisfies ... - 키별 타입을 살리고 싶으면
defineX()같은 제네릭 헬퍼 - 사용부에서 타입 고정이 목적이면
: T또는as T를 명시
이 3가지만 체계적으로 적용해도 “satisfies를 썼는데 왜 타입 좁힘이 안 되지?”라는 혼란은 대부분 사라집니다.