Published on

TS 5.5 is 검사로 never 오류 해결하기

Authors

서로 다른 도메인 타입을 다루다 보면 is 타입 가드로 narrowing을 유도하는 코드가 자주 등장합니다. 그런데 TS 5.5로 올리면서 갑자기 조건문 안에서 변수가 never로 좁혀지거나, 반대로 전혀 좁혀지지 않아 분기 로직이 무너지는 경험을 하는 경우가 있습니다.

이 글에서는 TS 5.5에서 is 검사와 관련해 never가 튀어나오는 대표 패턴을 재현한 뒤, 왜 그런지(타입 가드의 계약 관점), 그리고 실무에서 안전하게 고치는 방법을 코드 중심으로 정리합니다.

관련해서 TS 5.5의 narrowing 변화로 const인데도 좁혀지지 않는 케이스가 있다면 아래 글도 함께 보면 이해가 더 빨라집니다.

문제: is 타입 가드 이후 변수가 never가 된다

never는 보통 두 가지 상황에서 나타납니다.

  1. 코드 경로상 도달 불가능하다고 컴파일러가 판단했을 때
  2. 타입 연산 결과가 공집합(교집합이 없음)일 때

TS 5.5에서 자주 마주치는 건 2번입니다. 특히 사용자 정의 타입 가드의 반환 타입이 실제 파라미터 타입과 논리적으로 맞지 않으면, narrowing 결과가 공집합이 되어 never가 됩니다.

재현 예제: 잘못된 타입 가드 시그니처

아래 코드는 얼핏 그럴듯하지만, 시그니처 자체가 위험합니다.

type Cat = { kind: "cat"; meow: () => void };
type Dog = { kind: "dog"; bark: () => void };

type Pet = Cat | Dog;

// 잘못된 예: 매개변수 타입이 Pet인데, 반환은 Dog라고 단정
function isDog(pet: Pet): pet is Dog {
  // 구현이 더 큰 문제. kind 체크가 아니라 임의의 속성 존재로 판정
  return "bark" in pet;
}

function handle(pet: Pet) {
  if (isDog(pet)) {
    pet.bark();
  } else {
    // 여기서 pet이 Cat으로 좁혀질 거라 기대하지만,
    // 구현/시그니처가 어긋나면 TS가 더 공격적으로 공집합을 만들 수 있음
    pet.meow();
  }
}

위 예시는 단순해 보이지만, 실제 코드에서는 다음과 같은 형태로 never가 더 쉽게 발생합니다.

  • 타입 가드가 제네릭을 섞어 쓰면서 반환 타입이 파라미터와 무관해짐
  • is가 특정 유니온 멤버를 가리키는 것처럼 보이지만, 입력 타입이 이미 더 좁거나 다른 유니온임
  • as any로 구현을 뭉개서 컴파일러가 “이 가드는 신뢰할 수 없다”는 방향으로 추론을 강화함

TS 5.5에서 왜 더 자주 보이나

핵심은 “타입 가드는 계약이다”라는 점입니다.

(x: A) => x is B 라는 시그니처는 다음을 약속합니다.

  • 반환값이 true이면, 런타임에서 x는 반드시 B여야 한다
  • 반환값이 false이면, 런타임에서 xA에서 B를 뺀 나머지여야 한다

TS 5.5에서는 이 계약이 깨질 여지가 있는 패턴(특히 제네릭, 조건부 타입, 구조적 타이핑이 섞인 경우)에 대해 narrowing 결과를 더 엄격하게 계산하면서 공집합이 만들어지는 상황이 늘었습니다. 그 결과가 never로 표면화됩니다.

즉, TS 5.5가 “갑자기 이상해졌다”기보다는, 기존 코드에 숨어 있던 타입 가드의 불일치가 더 잘 드러난 것입니다.

가장 흔한 원인 3가지와 해결법

1) 타입 가드가 입력 타입과 독립적인 타입을 반환한다

실무에서 많이 보는 안티패턴은 이런 형태입니다.

function isNonEmptyString(x: unknown): x is string {
  return typeof x === "string" && x.length > 0;
}

function isAdmin(user: { role: "admin" } | { role: "member" }): user is { role: "admin" } {
  return user.role === "admin";
}

// 문제 패턴: 입력이 T인데 반환은 특정 타입 S로 고정
function isFoo<T>(x: T): x is { foo: string } {
  return typeof (x as any)?.foo === "string";
}

T{ foo: string }와 교집합이 없는 타입으로 들어오면, x is { foo: string }는 논리적으로 성립할 수 없습니다. TS 5.5에서는 이런 상황에서 분기 이후 타입이 never로 좁혀질 수 있습니다.

해결: 반환 타입을 T와 연결하라

가드가 “T의 부분집합”을 반환하도록 시그니처를 바꿔야 합니다.

type HasFoo = { foo: string };

function hasFoo<T>(x: T): x is T & HasFoo {
  return typeof (x as any)?.foo === "string";
}

function demo<T>(x: T) {
  if (hasFoo(x)) {
    // x: T & { foo: string }
    x.foo;
  }
}

핵심은 x is T & HasFoo처럼 입력 타입을 보존하면서 속성을 추가하는 방식입니다.

2) 판별 기준이 불안정한 구조적 체크다

"prop" in obj 같은 구조적 체크는 간편하지만, 유니온 멤버가 늘어나거나 선택적 프로퍼티가 섞이면 가드의 의미가 흔들립니다.

type A = { type: "a"; value: number };
type B = { type: "b"; value: number; extra?: string };

type U = A | B;

function isB(u: U): u is B {
  return "extra" in u; // extra가 optional이라면 이 체크는 본질적으로 불안정
}

이런 가드는 어떤 입력에서는 true가 될 수 없고, 어떤 입력에서는 우연히 false가 되는 등 계약 위반 가능성이 큽니다. TS 5.5에서 narrowing이 꼬이면 never가 튀는 도화선이 됩니다.

해결: discriminant(판별자) 기반으로 가드를 작성하라

type A2 = { kind: "a"; value: number };
type B2 = { kind: "b"; value: number; extra?: string };

type U2 = A2 | B2;

function isB2(u: U2): u is B2 {
  return u.kind === "b";
}

function handle(u: U2) {
  if (isB2(u)) {
    // u: B2
    u.extra;
  } else {
    // u: A2
    u.value;
  }
}

판별자 기반 narrowing은 TS가 가장 잘 이해하는 패턴이고, 리팩터링에도 강합니다.

3) assertsis를 섞어 쓰며 흐름이 꼬인다

타입 가드에는 asserts 기반도 있습니다. asserts x is T는 실패 시 예외를 던지고 정상 종료 시 타입을 보장하는 계약입니다.

가끔 아래처럼 isasserts를 뒤섞어 사용하면서 흐름이 애매해지고, 결과적으로 도달 불가능 분기 또는 공집합 narrowing이 생깁니다.

해결: 검증 함수는 asserts로, 분기 함수는 is로 역할을 분리

type User =
  | { kind: "guest" }
  | { kind: "member"; id: string };

function assertMember(u: User): asserts u is Extract<User, { kind: "member" }> {
  if (u.kind !== "member") throw new Error("member required");
}

function isMember(u: User): u is Extract<User, { kind: "member" }> {
  return u.kind === "member";
}

function route(u: User) {
  if (isMember(u)) {
    u.id;
    return;
  }

  // 여기서는 guest
  u.kind;
}

function loadProfile(u: User) {
  assertMember(u);
  // 여기부터는 member로 확정
  u.id;
}

Extract를 쓰면 유니온에서 특정 멤버를 정확히 뽑아낼 수 있어, 반환 타입이 입력 유니온과 논리적으로 정합성을 유지합니다.

never를 “해결”하는 체크리스트

TS 5.5에서 is 검사 후 never가 발생하면, 아래 순서로 점검하면 대부분 빠르게 정리됩니다.

  1. 타입 가드 시그니처가 입력 타입의 부분집합을 반환하는가
    • 제네릭이면 x is T & Something 또는 x is Extract<T, Something> 형태를 우선 고려
  2. 구현이 판별자 기반인가
    • 가능하면 kind, type, tag 같은 discriminant를 도입
  3. 선택적 프로퍼티 존재 여부로 판정하고 있지 않은가
    • "p" in x는 optional과 만나면 의미가 약해짐
  4. 반환 타입이 실제로 공집합이 되는 조합은 없는가
    • 예: Tstring인데 x is { foo: string } 같은 형태
  5. as any로 구현을 뭉개고 있지 않은가
    • 런타임 체크를 더 명확히 쓰는 쪽이 TS 5.5에서 오히려 안정적

실전 패턴: API 응답 파서에서 안전하게 narrowing하기

백엔드 응답을 파싱할 때 unknown에서 시작해 유니온으로 좁히는 패턴이 많습니다. 이때 타입 가드가 불안정하면 TS 5.5에서 never가 쉽게 터집니다.

아래는 안정적인 구성 예시입니다.

type ApiOk = { ok: true; data: { id: string } };
type ApiErr = { ok: false; error: { code: string; message: string } };

type ApiResp = ApiOk | ApiErr;

function isApiResp(x: unknown): x is ApiResp {
  if (typeof x !== "object" || x === null) return false;
  const o = x as any;
  return typeof o.ok === "boolean";
}

function isOk(r: ApiResp): r is ApiOk {
  return r.ok === true;
}

async function fetchThing(): Promise<string> {
  const raw: unknown = await Promise.resolve({ ok: true, data: { id: "1" } });

  if (!isApiResp(raw)) {
    throw new Error("invalid response");
  }

  if (isOk(raw)) {
    return raw.data.id;
  }

  // 여기서 raw는 ApiErr
  throw new Error(raw.error.message);
}

포인트는 다음과 같습니다.

  • unknown에서 ApiResp로 가는 1차 가드와
  • ApiResp에서 ApiOk로 가는 2차 가드를 분리
  • 둘 다 판별자(ok) 기반

이렇게 하면 TS 5.5에서도 narrowing이 흔들릴 여지가 거의 없습니다.

마무리

TS 5.5에서 is 검사 후 never가 뜨는 문제는 대개 “타입 가드의 계약이 입력 타입과 정합하지 않다”는 신호입니다. 단순히 as로 덮기보다,

  • 반환 타입을 입력 타입과 연결하고
  • 판별자 기반 체크로 바꾸며
  • ExtractT & X로 부분집합을 정확히 표현

하는 쪽이 장기적으로 훨씬 안전합니다.

TS 5.5 업그레이드 과정에서 narrowing 관련 이슈를 더 폭넓게 점검하려면 아래 글도 함께 참고하세요.