Published on

TypeScript 5.5 never 좁히기 깨짐? 해결 6가지

Authors

서론

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;
  }
}

해결

  1. “기타 케이스”도 리터럴로 고정하거나
  2. 아예 별도 타입으로 분리하고, 호출 경계에서 정규화하세요.
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);
}

여기서 포인트는 handlersRecord<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가지만 해도 원인 찾는 속도가 빨라집니다.

  1. 타입을 출력해보기: type T = typeof x 같은 별칭을 만들고 IDE에서 펼쳐보기
  2. 유니온이 열려 있는지 확인: kind: string, 인덱스 시그니처([k: string]: any), any 유입 여부
  3. 가드 시그니처 점검: function isX(v): v is X 형태인지, 그냥 boolean 반환인지

never는 결과가 아니라 “타입 설계가 닫혀 있고, 분기 조건이 타입 시스템이 이해할 수 있게 작성되었다”는 신호에 가깝습니다.


결론

TypeScript 5.5에서 never 좁히기가 깨져 보이는 현상은 대개 아래 6가지로 정리됩니다.

  1. kind: string 같은 열린 discriminant가 섞여 유니온이 닫히지 않음
  2. in 체크/옵셔널 프로퍼티로는 부족해 타입 가드 시그니처가 필요
  3. filter(Boolean) 같은 관용구는 타입 가드가 아니라 좁히기가 약함
  4. switch 대신 satisfies 기반 맵으로 exhaustiveness를 강제
  5. any/부정확한 외부 입력이 좁히기를 무력화 → unknown + 검증
  6. tsconfig/의존성 타입/환경 버전 차이로 유니온이 넓어짐 → 버전 핀/체크리스트

업그레이드 후 타입이 흔들릴수록, “컴파일러가 이해할 수 있는 형태로 타입을 닫고(리터럴), 경계에서 검증하고(unknown), 가드를 명시한다(x is T)”가 가장 확실한 해법입니다.