- Published on
TypeScript 5.5 never 좁히기 깨짐? 해결 6가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 5.5로 올린 뒤, 분명히 if/switch로 분기했는데도 타입이 끝까지 좁혀지지 않거나, 마지막에 never로 떨어져야 할 코드가 unknown/any처럼 남아 "왜 여기서 컴파일이 안 되지?" 같은 상황을 만나는 경우가 있습니다. 흔히는 **제어 흐름 기반 타입 좁히기(control flow analysis)**가 기대와 다르게 동작하면서 발생합니다.
이 글에서는 “TypeScript 5.5에서 never 좁히기가 깨졌다”라고 느끼게 만드는 패턴을 몇 가지로 분해하고, 실무에서 재현 가능한 예제와 함께 해결 6가지를 정리합니다. (결론부터 말하면, 대부분은 TS 버그라기보다 코드 패턴/타입 설계/가드 함수의 시그니처 문제로 설명됩니다.)
비슷한 결로, 환경이 바뀌면서 갑자기 동작이 깨지는 문제는 인프라에서도 흔합니다. 예를 들어 EKS에서 컨트롤러 설치 후 403이 나는 경우처럼요. 이런 “업그레이드 후 깨짐” 문제를 다루는 글도 참고해보세요: EKS AWS Load Balancer Controller 설치 후 403 해결
증상: never로 떨어져야 하는데 안 떨어진다
아래 같은 패턴에서 기대는 “마지막 default는 도달 불가능이니 never”인데, 실제로는 컴파일러가 never로 확정하지 못해 에러가 나거나, 반대로 never로 잘못 좁혀져서 접근이 막히는 식입니다.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; size: number };
function assertNever(x: never): never {
throw new Error("Unexpected: " + JSON.stringify(x));
}
function area(s: Shape) {
switch (s.kind) {
case "circle":
return Math.PI * s.r ** 2;
case "square":
return s.size ** 2;
default:
return assertNever(s); // 기대: OK
}
}
이게 깨진다면 대개 Shape가 실제로는 더 넓거나(예: kind: string 섞임), 가드가 부정확하거나, 분기 조건이 “타입 시스템이 이해할 수 있는 형태”가 아니기 때문입니다.
해결 1) Discriminated Union을 “진짜”로 만들기 (kind: string 섞지 말기)
가장 흔한 원인은 유니온에 discriminant가 리터럴이 아닌 케이스가 섞이는 것입니다.
문제 예
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; size: number }
| { kind: string; payload: unknown }; // <- 이 한 줄이 모든 좁히기를 망침
function area(s: Shape) {
switch (s.kind) {
case "circle":
return Math.PI * s.r ** 2;
case "square":
return s.size ** 2;
default:
// s는 never가 아니라 { kind: string; payload: unknown } 가능성이 남음
return 0;
}
}
해결
- “기타 케이스”도 리터럴로 고정하거나
- 아예 별도 타입으로 분리하고, 호출 경계에서 정규화하세요.
type KnownShape =
| { kind: "circle"; r: number }
| { kind: "square"; size: number };
type UnknownShape = { kind: "unknown"; payload: unknown };
type Shape = KnownShape | UnknownShape;
function area(s: Shape) {
switch (s.kind) {
case "circle":
return Math.PI * s.r ** 2;
case "square":
return s.size ** 2;
case "unknown":
return 0;
}
}
핵심: kind: string 같은 “열린” discriminant는 switch의 exhaustiveness를 깨뜨립니다.
해결 2) in/프로퍼티 존재 체크는 “정확한 가드 함수”로 감싸기
"prop" in obj는 유용하지만, 유니온이 복잡해질수록 TS가 원하는 수준으로 좁히지 못하는 경우가 있습니다. 특히 교차 타입/제네릭/옵셔널 프로퍼티가 섞이면 더 그렇습니다.
문제 예
type A = { type: "a"; a?: number };
type B = { type: "b"; b: number };
type U = A | B;
function f(x: U) {
if ("b" in x) {
// 여기서 x가 B로 확정되지 않는 경우가 생길 수 있음(옵셔널/구조에 따라)
return x.b;
}
return x.a ?? 0;
}
해결: 사용자 정의 타입 가드
function hasB(x: A | B): x is B {
return (x as any).b !== undefined;
}
function f(x: A | B) {
if (hasB(x)) {
return x.b; // OK
}
return x.a ?? 0;
}
가드 함수의 x is B 시그니처는 TS의 제어 흐름 분석에 “명시적 힌트”를 줍니다.
해결 3) filter(Boolean)/filter(x => x)로는 never 좁히기 기대하지 말기
업그레이드 후 특히 많이 보이는 착각 포인트가 배열 필터링입니다. filter(Boolean)는 런타임에 falsy를 제거하지만, 타입 시스템 관점에서는 “무슨 값이 제거됐는지”를 충분히 설명하지 못합니다.
문제 예
const xs: Array<string | undefined> = ["a", undefined, "b"];
const ys = xs.filter(Boolean);
// 기대: string[]
// 현실: (string | undefined)[] 로 남는 경우가 많음
해결: 타입 가드 필터 사용
function isDefined<T>(x: T | undefined | null): x is T {
return x != null;
}
const xs: Array<string | undefined> = ["a", undefined, "b"];
const ys = xs.filter(isDefined); // string[]
이 패턴은 “never로 떨어져야 할 케이스가 남는다” 류의 문제를 줄이는 데도 효과적입니다.
해결 4) switch exhaustiveness는 satisfies + 맵 패턴으로 더 강하게 고정하기
switch는 읽기 쉽지만, 타입이 커질수록 누락이 생기기 쉽습니다. TS 4.9+의 satisfies를 활용해 케이스 누락을 컴파일 타임에 강제하면 “never 좁히기”에 의존하는 빈도를 줄일 수 있습니다.
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; size: number };
type Kind = Shape["kind"];
const handlers = {
circle: (s: Extract<Shape, { kind: "circle" }>) => Math.PI * s.r ** 2,
square: (s: Extract<Shape, { kind: "square" }>) => s.size ** 2,
} satisfies Record<Kind, (s: any) => number>;
function area(s: Shape) {
return handlers[s.kind](s as any);
}
여기서 포인트는 handlers가 Record<Kind, ...>를 만족해야 하므로 Kind가 늘어나면 컴파일이 깨져서 누락을 즉시 잡아줍니다.
해결 5) never가 “깨진” 게 아니라, 실제로는 any/unknown이 섞여서 좁히기가 무력화된 경우
타입 좁히기에서 any는 블랙홀입니다. any가 한 번 섞이면 제어 흐름 분석이 사실상 의미가 없어지고, “왜 never로 안 떨어지지?” 같은 현상이 생깁니다.
문제 예: 외부 입력을 any로 받는 경우
declare const input: any;
type Event =
| { type: "click"; x: number; y: number }
| { type: "view"; url: string };
const e: Event = input; // 여기서 이미 any가 침투
if (e.type === "click") {
e.x; // 컴파일러가 제대로 보호 못할 수 있음
}
해결: 경계에서 unknown + 런타임 검증
declare const input: unknown;
type Click = { type: "click"; x: number; y: number };
type View = { type: "view"; url: string };
type Event = Click | View;
function isEvent(x: unknown): x is Event {
if (typeof x !== "object" || x === null) return false;
const t = (x as any).type;
if (t === "click") return typeof (x as any).x === "number" && typeof (x as any).y === "number";
if (t === "view") return typeof (x as any).url === "string";
return false;
}
if (!isEvent(input)) throw new Error("Invalid event");
const e = input;
switch (e.type) {
case "click":
console.log(e.x, e.y);
break;
case "view":
console.log(e.url);
break;
}
런타임 검증을 붙이면 TS가 다시 “정상적인 유니온”으로 좁힐 수 있습니다.
해결 6) 업그레이드 후 회귀처럼 보이면: tsconfig/의존성 타입 정의/버전 고정 점검
TypeScript 5.5 자체 변경이라기보다, 업그레이드 타이밍에 함께 바뀐 것들이 never 좁히기를 흔듭니다.
체크리스트
skipLibCheck: true를 켜서 숨겨진 타입 오류가 런타임 버그로 이어지고 있지 않은지(가능하면 끄고 해결)exactOptionalPropertyTypes,noUncheckedIndexedAccess같은 옵션을 새로 켰는지@types/*패키지 버전이 같이 올라가서 유니온이 넓어졌는지- 번들러/테스트 환경에서 서로 다른 TS 버전을 쓰는지(에디터 vs CI)
해결: 재현 최소화 + 버전 핀
package.json에서 TS를 명시적으로 고정하고, CI에서 tsc -v를 출력해 환경 차이를 제거하세요.
{
"devDependencies": {
"typescript": "5.5.4"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -v"
}
}
또한 pnpm/npm의 의존성 해석 차이로 타입 패키지가 달라지는 경우도 있어 lockfile 관리가 중요합니다. 이런 “갑자기 403/413 같은 에러가 튀는” 류의 환경 차이 문제를 다루는 글도 같이 보면 디버깅 감각을 키우는 데 도움이 됩니다: Git LFS 푸시 실패 413·403 원인과 해결법
실전: never 좁히기 디버깅 요령 3가지
문제가 재현될 때 아래 3가지만 해도 원인 찾는 속도가 빨라집니다.
- 타입을 출력해보기:
type T = typeof x같은 별칭을 만들고 IDE에서 펼쳐보기 - 유니온이 열려 있는지 확인:
kind: string, 인덱스 시그니처([k: string]: any),any유입 여부 - 가드 시그니처 점검:
function isX(v): v is X형태인지, 그냥 boolean 반환인지
never는 결과가 아니라 “타입 설계가 닫혀 있고, 분기 조건이 타입 시스템이 이해할 수 있게 작성되었다”는 신호에 가깝습니다.
결론
TypeScript 5.5에서 never 좁히기가 깨져 보이는 현상은 대개 아래 6가지로 정리됩니다.
kind: string같은 열린 discriminant가 섞여 유니온이 닫히지 않음in체크/옵셔널 프로퍼티로는 부족해 타입 가드 시그니처가 필요filter(Boolean)같은 관용구는 타입 가드가 아니라 좁히기가 약함switch대신satisfies기반 맵으로 exhaustiveness를 강제any/부정확한 외부 입력이 좁히기를 무력화 →unknown+ 검증- tsconfig/의존성 타입/환경 버전 차이로 유니온이 넓어짐 → 버전 핀/체크리스트
업그레이드 후 타입이 흔들릴수록, “컴파일러가 이해할 수 있는 형태로 타입을 닫고(리터럴), 경계에서 검증하고(unknown), 가드를 명시한다(x is T)”가 가장 확실한 해법입니다.