- Published on
TypeScript 5.5 좁히기 깨짐 - satisfies·in 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 “안전한 리팩터링 도구”로 쓰다 보면, 런타임 체크(in, typeof, instanceof)로 분기하고 그 안에서 자동으로 타입이 좁혀지길 기대하게 됩니다. 그런데 TypeScript 5.5로 올린 뒤, 기존에 잘 되던 좁히기(narrowing)가 갑자기 덜 되거나(또는 전혀 안 되거나), satisfies를 추가한 순간 분기 내부에서 타입이 넓게 남아 오류가 터지는 경험을 하는 팀이 꽤 있습니다.
이 글은 “TS 5.5에서 좁히기가 깨진 것처럼 보이는” 대표 패턴을 재현하고, 왜 그런지(타입 시스템 관점)와 함께 실무적으로 안정적인 해결책을 제시합니다. 핵심은 다음 두 가지입니다.
satisfies는 “검증”이지 “타입을 바꾸는 캐스팅”이 아니다.in은 강력하지만, 유니온의 각 멤버가 어떻게 정의돼 있느냐(옵셔널/인덱스 시그니처/교차 타입/제네릭)와 결합하면 좁히기 결과가 달라진다.
> 참고: 장애를 “재현 → 원인 분리 → 최소 수정으로 복구”하는 접근은 인프라 트러블슈팅에서도 동일합니다. 예를 들어 Kubernetes CrashLoopBackOff 원인 12가지와 진단처럼, 증상만 보고 바로 결론 내리기보다 조건을 분리해 원인을 좁히는 방식이 효과적입니다.
문제 1: satisfies를 붙였더니 분기 내부 좁히기가 약해짐
재현 코드
아래는 흔한 “API 응답 유니온” 예시입니다.
type Ok = { ok: true; data: { id: string } };
type Err = { ok: false; error: { message: string } };
type Res = Ok | Err;
const res = {
ok: true,
data: { id: "a" },
} satisfies Res;
if (res.ok) {
// 기대: res는 Ok로 좁혀져서 data 접근 가능
res.data.id;
} else {
res.error.message;
}
대부분의 환경에서 위 코드는 잘 동작합니다. 그런데 실무 코드에서는 res가 단순 객체 리터럴이 아니라 제네릭 함수 반환값, 교차 타입, 옵셔널 프로퍼티, 인덱스 시그니처 등을 끼고 들어오면서 satisfies가 “타입을 고정시키지 않는다”는 성질이 표면화됩니다.
핵심은 satisfies가 다음을 보장한다는 점입니다.
- “이 값은 Res에 할당 가능하다”를 컴파일 타임에 검증
- 하지만 값의 추론된 타입을 Res로 바꾸지 않는다
즉, as Res처럼 “이제부터 Res로 취급해”가 아니라 “Res 규격을 만족하는지 검사만 할게”에 가깝습니다.
실무에서 흔한 깨짐 패턴: 넓은 추론 타입 + in/불리언 체크
type A = { kind: "a"; a: number };
type B = { kind: "b"; b: string };
type U = A | B;
// 실무에서 종종 등장: 중간에 spread/조건부 필드가 섞이면서 추론이 넓어짐
const u = {
kind: Math.random() > 0.5 ? "a" : "b",
...(Math.random() > 0.5 ? { a: 1 } : { b: "x" }),
} satisfies U;
if (u.kind === "a") {
// 여기서 u.a가 number로 보장되길 기대하지만,
// 추론 타입이 애매해져서 u.a가 없을 수도 있다고 나올 수 있음
console.log(u.a);
}
이 코드는 “U를 만족”하긴 하지만, 객체 리터럴이 조건부로 구성되면서 추론 타입이 kind: "a" | "b" + a?: number + b?: string 같은 형태로 남을 수 있습니다. 그러면 kind === "a"만으로 a가 반드시 존재한다고 결론 내리기 어려워지고, 좁히기가 기대보다 약해집니다.
해결 1) satisfies 결과를 “유니온 타입 변수”로 한 번 고정
가장 간단한 해법은 “검증”과 “타입 고정”을 분리하는 것입니다.
const raw = {
kind: Math.random() > 0.5 ? "a" : "b",
...(Math.random() > 0.5 ? { a: 1 } : { b: "x" }),
} satisfies U;
const u: U = raw; // 여기서부터는 U로 좁히기 동작이 예측 가능해짐
if (u.kind === "a") {
console.log(u.a); // OK
}
포인트:
satisfies는 유지(객체 리터럴 과잉 속성 검사/형태 검증용)- 실제 제어 흐름 좁히기는
U로 고정된 변수에서 수행
해결 2) 태그(Discriminant)를 더 강하게: kind는 리터럴로 유지
위 예시가 깨지는 이유 중 하나는 kind가 리터럴이 아니라 "a" | "b"로 넓게 남기 때문입니다. 가능하면 생성 단계에서부터 분기별로 “완성된 객체”를 만들면 좁히기가 훨씬 견고해집니다.
function makeU(): U {
if (Math.random() > 0.5) return { kind: "a", a: 1 };
return { kind: "b", b: "x" };
}
const u = makeU();
if (u.kind === "a") {
u.a; // OK
}
문제 2: in으로 체크했는데 TS 5.5에서 좁히기가 기대보다 덜 됨
in은 “이 프로퍼티 키가 객체에 존재하는가”를 런타임에서 검사합니다.
type WithId = { id: string; name: string };
type WithEmail = { email: string; name: string };
type User = WithId | WithEmail;
function print(u: User) {
if ("id" in u) {
u.id; // 기대: WithId
} else {
u.email; // 기대: WithEmail
}
}
이건 보통 잘 됩니다. 하지만 다음 조건이 끼면 좁히기가 흐려집니다.
- 한쪽 멤버에 해당 키가 옵셔널(
id?: string)인 경우 - 인덱스 시그니처가 있는 경우 (
[k: string]: unknown) - 교차 타입/제네릭으로 인해 “모든 멤버가 그 키를 가질 수도 있음”처럼 보이는 경우
재현: 옵셔널 프로퍼티가 섞인 유니온
type A = { type: "a"; id?: string };
type B = { type: "b"; email: string };
type U = A | B;
function f(u: U) {
if ("id" in u) {
// "id"는 A에서 optional이라 존재 여부만으로 A 확정이 약해짐
// 결과적으로 u가 A로 완전히 좁혀지지 않을 수 있음
u.type;
}
}
"id" in u는 “런타임에 id 키가 존재”를 말하지만, 타입 레벨에서는 A가 id?를 갖기 때문에 “A일 수도 있고, (다른 멤버도 구조상 id를 가질 수 있다면) 다른 멤버일 수도”가 됩니다.
해결 1) in 대신 태그 기반 좁히기(가장 안정적)
가능하면 in보다 discriminated union(태그 필드)로 설계하는 게 가장 강력합니다.
type A = { type: "a"; id?: string };
type B = { type: "b"; email: string };
type U = A | B;
function f(u: U) {
if (u.type === "a") {
// u는 A
u.id;
} else {
// u는 B
u.email;
}
}
해결 2) in을 써야 한다면: 사용자 정의 타입 가드로 “의도”를 고정
in 자체가 나쁜 게 아니라, “이 분기가 어떤 타입을 의미하는지”를 컴파일러가 애매하게 보는 상황이 문제입니다. 그럴 땐 가드 함수로 의도를 명시합니다.
type WithId = { id: string };
type A = { type: "a"; id?: string };
type B = { type: "b"; email: string };
type U = A | B;
function hasId(x: U): x is A & WithId {
return "id" in x && typeof (x as any).id === "string";
}
function f(u: U) {
if (hasId(u)) {
// 여기서는 id가 string임까지 보장
u.id.toUpperCase();
} else {
u.email;
}
}
포인트:
x is ...로 “이 조건이 참이면 어떤 타입”인지 선언- optional/unknown을 런타임에서 추가 검증하여 타입을 강화
해결 3) in 체크 결과를 별도 변수로 분리(제어 흐름 분석 안정화)
복잡한 표현식(특히 제네릭/고차 함수 내부)에서 TS의 제어 흐름 분석이 덜 공격적으로 동작하는 경우가 있습니다. 이때는 조건을 분리하면 개선되는 경우가 많습니다.
function f(u: U) {
const has = "email" in u;
if (has) {
u.email;
} else {
u.type; // A
}
}
이 패턴은 “컴파일러가 추론을 더 잘하게” 만든다기보다, 팀 차원에서 읽기 쉬워지고 디버깅이 쉬워지는 장점이 큽니다.
satisfies와 in을 같이 쓸 때 권장하는 안전 패턴
실무에서 가장 많이 보는 조합은 “스키마를 만족하는지 satisfies로 검증”하고, “런타임 분기는 in으로 처리”하는 형태입니다. 이때는 아래 순서를 추천합니다.
satisfies로 형태 검증- 검증된 값을 명시적 유니온 타입 변수로 바인딩
- 분기는 가능하면 태그 기반, 불가하면 타입 가드로 강화
예시: 이벤트 페이로드 처리
type Signup = { event: "signup"; userId: string };
type Purchase = { event: "purchase"; orderId: string; amount: number };
type Event = Signup | Purchase;
const payload = {
event: Math.random() > 0.5 ? "signup" : "purchase",
...(Math.random() > 0.5
? { userId: "u1" }
: { orderId: "o1", amount: 100 }),
} satisfies Event;
const e: Event = payload;
switch (e.event) {
case "signup":
console.log(e.userId);
break;
case "purchase":
console.log(e.orderId, e.amount);
break;
}
이렇게 하면 satisfies의 장점(객체 리터럴 검증)을 살리면서도, 좁히기는 “전형적인 discriminated union” 경로로 안정화됩니다.
디버깅 체크리스트: 좁히기가 깨져 보일 때 빠르게 확인할 것
satisfies는 타입을 바꾸지 않는다- 분기에서 쓰는 변수의 타입이 정말 유니온인지 확인
- 필요하면
const x: U = value로 한 번 고정
유니온 멤버 중 해당 키가 optional인지 확인
in은 “존재” 체크인데 optional이면 타입 결론이 약해짐
인덱스 시그니처/
Record<string, ...>가 섞였는지 확인"foo" in obj가 “항상 가능”처럼 보이면 좁히기 어려움
교차 타입(
&)로 인해 모든 멤버가 같은 키를 갖는 구조가 됐는지 확인최후의 수단은
as가 아니라 타입 가드 함수- 런타임 검증과 타입 선언을 한 곳에 묶어 유지보수성을 높임
> 인증/권한 문제를 디버깅할 때도 “토큰이 맞나?”만 보는 게 아니라 캐시, 키 회전, kid 불일치 등 조건을 분해해야 합니다. 비슷한 방식의 점검 목록은 JWT 검증 실패 - JWKS kid 불일치·캐시 7가지도 참고할 만합니다.
결론
TypeScript 5.5에서 “좁히기가 깨졌다”는 느낌은 대개 컴파일러 버그라기보다, 다음의 조합에서 발생합니다.
satisfies로 검증했지만, 실제로는 추론 타입이 넓게 남아 분기 좁히기가 약해짐in체크가 optional/인덱스 시그니처/교차 타입과 결합해 “결정적 증거”가 되지 못함
가장 안전한 해결책은:
satisfies는 검증용으로 쓰되, 분기 로직에 들어가기 전에const x: Union = value로 타입을 고정- 가능하면
in보다 태그 기반(discriminated union)으로 설계 in이 필요하면 사용자 정의 타입 가드로 런타임 검증 + 타입 선언을 결합
이 3가지만 팀 컨벤션으로 잡아도, TS 5.5 이후에도 좁히기 관련 이슈를 대부분 예방할 수 있습니다.