Published on

TS 5.5에서 type predicate 깨짐 해결법

Authors

서버/프런트 공통으로 TypeScript를 오래 운영하다 보면, “업그레이드했더니 기존 타입 가드가 깨졌다”는 상황을 한 번쯤 겪습니다. 특히 TS 5.5로 올라오면서 type predicate(사용자 정의 타입 가드)와 관련된 경고/에러가 늘었다고 느끼는 팀이 많습니다. 실제로는 타입 시스템의 “더 엄격한 추론”과 “제어 흐름 기반 좁히기(control flow analysis)”가 개선되면서, 이전 버전에서 우연히 통과하던 코드가 이제는 명확히 문제로 드러나는 경우가 많습니다.

이 글은 TS 5.5에서 자주 깨지는 타입 가드 패턴을 재현 코드로 분류하고, 가장 안전한 해결책을 제시합니다. 빌드 에러를 억지로 잠재우는 as 남발 대신, 코드의 의도를 타입 시스템이 이해하도록 만드는 방향에 초점을 맞춥니다.

문제 해결 글을 읽을 때 “원인-재현-해결-재발 방지” 흐름이 가장 도움이 되는데, 이 접근은 예를 들어 JWT invalid signature 서명검증 실패 원인 7가지 같은 디버깅 글의 구조와도 유사합니다. 타입 문제도 결국은 재현 가능한 원인을 분해하는 게 핵심입니다.

Type predicate 기본: 무엇이 보장되어야 하나

Type predicate는 다음 형태입니다.

function isFoo(x: unknown): x is Foo {
  // 런타임 체크
}

여기서 중요한 전제는 “함수 본문이 실제로 xFoo임을 런타임에서 보장해야 한다”는 점입니다. TS는 타입 가드의 본문을 완전히 증명하진 않지만, 명백히 모순되는 선언이나 제어 흐름상 보장되지 않는 반환에 대해서는 점점 더 엄격해지고 있습니다.

TS 5.5에서 깨졌다고 느끼는 케이스는 대체로 아래 중 하나입니다.

  1. predicate가 가리키는 값이 실제 파라미터가 아닌 다른 값을 좁히려는 경우
  2. 제네릭/조건부 타입과 결합되어 “좁히기 결과”가 모호해진 경우
  3. filter 같은 고차 함수에서 predicate 시그니처가 미묘하게 맞지 않는 경우
  4. any/unknown/유니온이 섞여 “런타임 체크”와 “타입 선언”이 불일치한 경우

케이스 1) 파라미터가 아닌 값을 좁히려는 type predicate

가장 흔한 실수는 “predicate의 대상은 반드시 해당 파라미터여야 한다”는 규칙을 어기는 것입니다.

깨지는 예

type User = { id: string };

let cached: unknown;

function isUser(_: unknown): _ is User {
  // 실제로는 cached를 검사하고 있음
  return typeof cached === "object" && cached !== null && "id" in (cached as any);
}

이 코드는 겉으로는 _User로 좁힌다고 선언하지만, 실제 체크는 cached에 대해 수행합니다. TS 5.5에서는 이런 패턴이 더 자주 문제를 일으키거나(특히 호출부 좁히기가 기대대로 안 되거나) 리뷰에서 걸리기 쉽습니다.

해결: 검사 대상과 predicate 대상을 일치시키기

type User = { id: string };

function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null && "id" in x;
}

const cached: unknown = { id: "u1" };
if (isUser(cached)) {
  cached.id;
}

추가로 "id" in xx가 객체일 때만 안전하므로 typeofnull 체크를 먼저 둡니다.

케이스 2) filter에서 type predicate가 깨지는 패턴

TS 5.5에서 체감상 많이 등장하는 이슈가 Array.prototype.filter와 타입 가드 조합입니다. 특히 “불리언 반환 함수”와 “type predicate 반환 함수”를 섞어 쓰다가 타입이 기대보다 덜 좁혀지는 경우가 많습니다.

깨지는 예: Boolean 필터에 기대기

const xs = [0, 1, 2, null, undefined];
const ys = xs.filter(Boolean);
// 기대: number[]
// 현실: (number | null | undefined)[] 로 남는 경우가 흔함

이건 TS 5.5만의 변화라기보다 원래부터 애매했지만, 업그레이드 후 다른 추론 변화와 겹치면서 “이전엔 우연히 number[]처럼 보이던 것”이 더 이상 안 맞을 수 있습니다.

해결: 명시적인 타입 가드 함수 제공

function isNotNullish<T>(x: T): x is NonNullable<T> {
  return x !== null && x !== undefined;
}

const xs = [0, 1, 2, null, undefined];
const ys = xs.filter(isNotNullish);
// ys: number[]

NonNullable은 표준 유틸리티 타입이라 안정적이고, 런타임 체크와 타입 선언의 대응도 명확합니다.

해결: 인라인 predicate를 쓰되 시그니처를 맞추기

const ys = xs.filter((x): x is number => typeof x === "number");

이때 x is number처럼 반환 타입을 predicate로 명시해야 filter가 결과 타입을 제대로 좁힙니다.

케이스 3) 제네릭 type predicate의 과신: T is ...가 성립하지 않는 경우

제네릭과 type predicate를 결합하면 강력하지만, 선언이 과도하면 TS 5.5에서 더 쉽게 “성립 불가”로 드러납니다.

위험한 예: T를 임의로 좁히는 척하기

function isString<T>(x: T): x is Extract<T, string> {
  return typeof x === "string";
}

겉보기엔 괜찮아 보이지만, 호출부에서 T가 너무 넓게 잡히거나(예: unknown) 너무 좁게 잡히는 경우(예: number)에 기대한 좁히기가 안 나오면서 “왜 predicate가 안 먹지?”가 됩니다.

실전 해법 1: 입력을 unknown으로 고정하고 결과만 좁히기

function isString(x: unknown): x is string {
  return typeof x === "string";
}

런타임 체크 기반 가드는 대개 unknown 입력이 가장 자연스럽습니다. 제네릭을 쓰는 순간 타입 추론이 복잡해지고, TS 버전 변화에 민감해집니다.

실전 해법 2: 정말 제네릭이 필요하면 “제약”을 추가

function isOfType<T extends string | number>(
  x: T | null,
  kind: "string" | "number"
): x is T {
  return x !== null && typeof x === kind;
}

이런 형태는 T의 후보군을 제한하므로 predicate의 의미가 훨씬 안정적입니다.

케이스 4) in 연산자와 인덱스 시그니처: 객체 판별이 느슨해진 코드

TS 5.5에서 갑자기 깨졌다기보다는, 업그레이드 후 다른 타입 변경과 함께 “객체 판별”이 더 자주 실패하는 패턴입니다.

흔한 예: Record<string, unknown>로 단정해 버리기

type Foo = { kind: "foo"; value: number };

function isFoo(x: unknown): x is Foo {
  const o = x as Record<string, unknown>;
  return o["kind"] === "foo" && typeof o["value"] === "number";
}

이 코드는 xnull이어도 as Record...로 통과해 버립니다. 런타임에서는 o["kind"] 접근이 터질 수 있고, TS가 더 엄격해지면 여기저기서 문제를 일으킵니다.

해결: 먼저 “진짜 객체”인지 확인하는 가드 추가

function isRecord(x: unknown): x is Record<string, unknown> {
  return typeof x === "object" && x !== null;
}

type Foo = { kind: "foo"; value: number };

function isFoo(x: unknown): x is Foo {
  if (!isRecord(x)) return false;
  return x["kind"] === "foo" && typeof x["value"] === "number";
}

이렇게 2단계로 쪼개면 가드가 재사용 가능해지고, 런타임 안정성도 올라갑니다.

케이스 5) asserts로 바꾸는 게 더 맞는 경우

어떤 함수는 “참/거짓을 반환해서 분기”하기보다, 실패 시 예외를 던지고 성공 시 타입을 보장하는 형태가 더 자연스럽습니다. 이때 asserts를 쓰면 TS 5.5에서도 의도가 명확해집니다.

예: API 응답 스키마를 강제하는 경우

type ApiUser = { id: string; email: string };

function assertApiUser(x: unknown): asserts x is ApiUser {
  if (typeof x !== "object" || x === null) throw new Error("not object");
  if (!("id" in x) || !("email" in x)) throw new Error("missing fields");
}

const data: unknown = JSON.parse("{\"id\":\"1\",\"email\":\"a@b.com\"}");
assertApiUser(data);
// 여기부터 data는 ApiUser
console.log(data.email);

이 패턴은 “유효하지 않으면 즉시 실패”라는 정책을 코드로 고정하므로, 이후 로직이 단순해집니다.

TS 5.5 업그레이드에서 안전하게 고치는 체크리스트

아래는 실제 마이그레이션에서 효과가 큰 순서입니다.

1) unknown 경계에서만 type guard를 쓰기

외부 입력(JSON, API, localStorage, querystring 등)은 unknown으로 받고, 가드로 좁히세요. 내부 도메인 객체에 대해서는 가드가 아니라 타입 설계를 개선하는 편이 낫습니다.

2) filter(Boolean)를 모두 점검하기

filter(Boolean)는 편하지만 타입이 안정적으로 좁혀진다는 보장이 약합니다. isNotNullish 같은 명시적 가드로 교체하면 TS 버전 변화에도 둔감합니다.

3) as Record<string, unknown> 단정을 줄이기

단정은 타입 시스템을 속여서 단기적으로는 편하지만, 업그레이드 시 “숨겨진 런타임 버그”가 터지는 지점이 됩니다. 이건 장애 디버깅에서 “원인을 숨기는 캐시” 같은 존재라서, 결국 비용이 커집니다. 비슷한 맥락으로 장애 원인을 단계적으로 좁히는 글로는 Kafka Exactly-Once 깨질 때 중복처리 방지 전략처럼 원인 분해 방식이 도움이 됩니다.

4) 가드는 “작고 조합 가능하게” 만들기

isRecord 같은 기본 가드 + 도메인 가드 조합이 가장 유지보수성이 좋습니다.

function isRecord(x: unknown): x is Record<string, unknown> {
  return typeof x === "object" && x !== null;
}

function hasString(x: Record<string, unknown>, key: string): x is Record<string, unknown> {
  return typeof x[key] === "string";
}

hasString을 더 정교하게 만들려면 반환 타입을 키 기반으로 모델링할 수도 있지만, 과도한 타입 퍼즐은 TS 버전 변화에 민감해질 수 있으니 팀의 숙련도에 맞춰 조절하세요.

실제로 “TS 5.5에서 깨진 것처럼 보이는” 대표 증상과 처방

증상 A: if 문 안에서 좁혀지지 않는다

  • 원인: predicate가 파라미터가 아닌 다른 값을 검사하거나, 반환 타입이 단순 boolean으로 추론됨
  • 처방: (x): x is Type => ... 형태로 명시하거나, 검사 대상과 predicate 대상을 일치

증상 B: filter 결과 타입이 그대로다

  • 원인: 콜백이 predicate로 인식되지 않음
  • 처방: 별도 함수로 분리해서 x is ... 시그니처 제공

증상 C: 가드 내부에서 as any가 많고 런타임 예외가 난다

  • 원인: null/비객체 케이스를 먼저 배제하지 않음
  • 처방: typeof x === "object" && x !== null 같은 1차 가드 추가

마무리: TS 5.5는 “깨뜨린” 게 아니라 “드러낸” 경우가 많다

TS 5.5 업그레이드 후 type predicate가 깨졌다면, 상당수는 기존 코드가 타입 시스템 관점에서 애매하거나 런타임 보장이 약했던 부분이 표면화된 것입니다. 해결의 핵심은 다음 두 가지입니다.

  • predicate 선언과 런타임 체크를 1:1로 맞추기
  • filter(Boolean) 같은 편법을 명시적 가드로 치환하기

이 원칙으로 정리하면 TS 버전이 더 올라가도(예: 5.6, 5.7) “또 깨지는” 경험을 크게 줄일 수 있습니다. 업그레이드 시점에 한 번만 고생하고, 이후에는 가드 유틸을 표준화해 팀 전체의 타입 안정성을 끌어올리는 쪽이 장기적으로 가장 이득입니다.