Published on

TS 5.5 inferred type predicates로 필터링 타입좁히기

Authors

서버나 프론트 어디서든 데이터는 대개 unknown 혹은 넓은 유니온으로 들어오고, 우리는 그걸 filter 로 걸러낸 뒤 안전하게 쓰고 싶어집니다. 그런데 TS 5.4 이전까지는 filter(Boolean) 같은 흔한 패턴이 런타임에서는 잘 동작해도, 타입 수준에서는 기대만큼 좁혀지지 않아 결국 타입 단언이나 별도 타입 가드 함수를 작성하는 일이 많았습니다.

TypeScript 5.5에서 추가된 inferred type predicates는 이런 “필터링 기반 타입좁히기”를 훨씬 자연스럽게 만들어 줍니다. 특히 Array.prototype.filter 처럼 콜백의 반환값이 boolean 인 경우에도, 컴파일러가 특정 패턴을 인식해 콜백이 사실상 타입 가드 역할을 한다고 추론해 줍니다.

이 글에서는 TS 5.5의 핵심 변화, 어디까지 자동으로 좁혀지는지, 어떤 경우는 여전히 명시적 타입 가드가 필요한지까지 실전 관점으로 정리합니다. (추가로 TS 5.x의 추론을 안정적으로 다루는 방법은 TS 5.x satisfies로 타입 추론 깨짐 해결하기 도 함께 참고하면 좋습니다.)

문제: filter 뒤에 타입이 안 좁혀지는 고질병

가장 흔한 예는 null 또는 undefined 제거입니다.

const raw: Array<string | null> = ["a", null, "b"];

const cleaned = raw.filter(Boolean);
// TS 5.4까지: (string | null)[] 로 남는 경우가 많음
// 기대: string[]

런타임에서 Booleannull 을 제거하지만, 타입 시스템은 Boolean 이 어떤 값을 걸러내는지 일반적으로 알기 어렵습니다. 그래서 많은 팀이 아래처럼 유틸을 만들곤 했습니다.

export const isNotNullish = <T>(v: T | null | undefined): v is T => v != null;

const cleaned = raw.filter(isNotNullish); // string[]

이 방식은 명확하지만, 매번 타입 가드 함수를 만들어야 하고 코드가 장황해집니다.

TS 5.5: inferred type predicates가 바꾼 것

TS 5.5의 핵심은 간단히 말해 다음입니다.

  • filter 같은 함수에 넘긴 콜백이 특정한 형태를 띠면
  • 컴파일러가 그 콜백을 value is SomeType 같은 타입 프레디케이트로 추론해서
  • 결과 배열 타입을 자동으로 좁혀준다

즉, 개발자가 명시적으로 v is T 를 적지 않아도, “이건 타입 가드로 쓰는 패턴이네”를 TS가 알아채는 경우가 생겼습니다.

예시 1: x != null 패턴

const raw: Array<string | null | undefined> = ["a", null, undefined, "b"];

const cleaned = raw.filter((x) => x != null);
// TS 5.5: string[] 로 좁혀질 수 있음

x != null 은 런타임에서 nullundefined 를 모두 제거하는 전형적인 패턴입니다. TS 5.5는 이런 형태를 인식해 결과를 NonNullable 계열로 좁히는 방향으로 추론합니다.

예시 2: typeof 체크로 유니온 분기

type Item = string | number | { id: string };
const items: Item[] = ["a", 1, { id: "x" }, 2];

const onlyNumbers = items.filter((x) => typeof x === "number");
// TS 5.5: number[]

const onlyStrings = items.filter((x) => typeof x === "string");
// TS 5.5: string[]

이전에도 일부 케이스는 좁혀졌지만, TS 5.5에서는 더 많은 경우에 “콜백이 타입 가드로 동작한다”는 추론이 강화되었습니다.

예시 3: in 연산자 기반의 구조적 체크

type User = { id: string; name: string };
type Guest = { guest: true };

type Actor = User | Guest;

const actors: Actor[] = [
  { id: "u1", name: "A" },
  { guest: true },
  { id: "u2", name: "B" },
];

const users = actors.filter((a) => "id" in a);
// TS 5.5: User[] 로 좁혀질 가능성이 큼

이 패턴은 런타임 안전성 측면에서 “정말 id 가 있는지”만 보장하므로, 실제로는 id 만 있고 name 이 없는 객체가 들어올 수 있는 환경이라면 더 강한 검증이 필요합니다. 하지만 타입이 이미 User | Guest 로 제한되어 있다면, "id" in a 는 합리적인 분기 조건이 됩니다.

어디까지 자동으로 되나: 기대치 조절

TS 5.5가 모든 filter 를 마법처럼 해결하는 건 아닙니다. 컴파일러가 추론할 수 있는 건 “전형적인 타입 가드 패턴”에 한정됩니다.

filter(Boolean) 는 여전히 조심

const mixed: Array<string | 0 | null | undefined> = ["a", 0, null, "b", undefined];

const cleaned = mixed.filter(Boolean);

여기서 런타임 Boolean0 도 제거합니다. 그런데 타입 관점에서 우리가 원하는 건 보통 nullundefined 만 제거하는 것입니다. 즉,

  • 런타임 의미: falsy 제거
  • 타입 의도: nullish 제거

가 서로 어긋날 수 있습니다.

그래서 데이터가 숫자 0, 빈 문자열 "" 같은 값들을 합법적으로 포함할 수 있다면 filter(Boolean) 는 버그를 만들기 쉽습니다. 이럴 때는 TS 5.5 여부와 상관없이 아래처럼 의도를 명확히 하세요.

const cleaned = mixed.filter((x) => x != null);
// 0은 유지, null/undefined만 제거

실전 패턴 1: API 응답 파이프라인에서 nullish 제거

예를 들어 API에서 아래 같은 형태로 들어온다고 합시다.

type ApiUser = {
  id: string;
  email?: string | null;
};

const res: ApiUser[] = [
  { id: "1", email: "a@a.com" },
  { id: "2", email: null },
  { id: "3" },
];

const emails = res
  .map((u) => u.email)
  .filter((e) => e != null);
// TS 5.5: string[]

이 패턴은 코드가 짧고 의도가 명확합니다. 특히 map 이후 filter 를 붙이는 흐름에서 “중간 결과를 변수로 빼지 않고도” 타입이 따라오는 것이 체감상 큽니다.

실전 패턴 2: 유니온 이벤트 스트림에서 특정 이벤트만 추출

type Event =
  | { type: "click"; x: number; y: number }
  | { type: "view"; url: string }
  | { type: "purchase"; orderId: string; amount: number };

const events: Event[] = [
  { type: "view", url: "/" },
  { type: "click", x: 10, y: 20 },
  { type: "purchase", orderId: "o1", amount: 100 },
];

const purchases = events.filter((e) => e.type === "purchase");
// TS 5.5: { type: "purchase"; orderId: string; amount: number }[]

const total = purchases.reduce((sum, p) => sum + p.amount, 0);

과거에는 이런 케이스도 잘 좁혀졌지만, 코드베이스가 커지면 filter 콜백이 복잡해지면서 추론이 깨지는 경우가 있습니다. TS 5.5의 개선은 이런 “자주 쓰는 분기 패턴”의 성공률을 높여줍니다.

실전 패턴 3: find 와 조합할 때도 읽기 쉬워짐

filter 로 타입을 좁혀놓으면 이후 연산이 단순해집니다.

type Node =
  | { kind: "file"; path: string }
  | { kind: "dir"; path: string; children: string[] };

const nodes: Node[] = [
  { kind: "file", path: "/a" },
  { kind: "dir", path: "/b", children: ["/b/1"] },
];

const dirs = nodes.filter((n) => n.kind === "dir");
const firstChildren = dirs[0]?.children;

dirsdir 전용 배열로 좁혀지면, 아래처럼 별도 단언 없이 안전한 접근이 가능합니다.

그래도 타입 가드가 필요한 순간

콜백이 복잡해지면 TS가 “이게 타입 가드다”라고 확신하기 어려워집니다.

예: 조건이 여러 개 섞인 경우

type A = { kind: "a"; a: string };
type B = { kind: "b"; b: number };

type U = A | B;
const list: U[] = [{ kind: "a", a: "x" }, { kind: "b", b: 1 }];

const maybeA = list.filter((x) => x.kind === "a" && x.a.length > 0);

여기서 x.a.length 접근 자체가 xA 일 때만 가능한데, TS가 이 콜백 전체를 타입 가드로 추론하지 못하면 오류가 나거나 결과 타입이 덜 좁혀질 수 있습니다.

이럴 때는 “검증 로직”과 “타입 분기”를 분리하는 게 좋습니다.

const isA = (x: U): x is A => x.kind === "a";

const onlyA = list.filter(isA).filter((x) => x.a.length > 0);

즉,

  • 첫 번째 filter 는 타입만 좁히는 용도
  • 두 번째 filter 는 값 조건 필터링

으로 나누면 타입 시스템도 단순해지고 가독성도 좋아집니다.

inferred type predicates를 잘 쓰는 팁

1) filter(Boolean) 대신 x != null 을 기본값으로

  • 의도가 nullish 제거라면 x != null
  • falsy 제거가 진짜 의도일 때만 Boolean

이 원칙 하나로 런타임 버그와 타입 애매함을 동시에 줄일 수 있습니다.

2) 좁히기 조건은 “전형적인 패턴”으로 유지

TS가 잘 인식하는 패턴은 보통 다음 계열입니다.

  • x != null
  • typeof x === "string" 같은 typeof
  • "prop" in x
  • x.kind === "..." 같은 discriminated union 체크

조건을 너무 창의적으로 쓰기보다, 팀 규칙으로 이런 패턴을 선호하면 자동 타입좁히기 효과를 더 자주 얻습니다.

3) 추론이 흔들리면 satisfies 로 경계를 고정

유니온/리터럴이 복잡한 데이터 구조에서는 타입이 “넓어져서” 필터가 잘 안 먹는 경우가 있습니다. 이럴 때 객체 리터럴에 satisfies 를 붙여 리터럴 정보를 유지하면, 후속 filter 의 타입좁히기 성공률이 올라갑니다.

관련 실전 내용은 TS 5.x satisfies로 타입 오류 줄이는 실전 에서 더 자세히 다뤘습니다.

마이그레이션 체크리스트

TS 5.5로 올렸는데도 filter 결과 타입이 기대만큼 안 줄어든다면 아래를 점검하세요.

  1. 콜백이 단순한지 확인: x != null, typeof, in, kind 체크 형태인가
  2. 원본 배열 타입이 너무 넓지 않은지 확인: any[], unknown[] 에서는 당연히 제한적
  3. 콜백 내부에서 다른 변수/함수 호출이 섞여 추론이 어려워지지 않았는지 확인
  4. 정말로 런타임에서 제거되는 값과 타입에서 제거하고 싶은 값이 일치하는지 확인

정리

TS 5.5의 inferred type predicates는 filter 를 중심으로 한 데이터 정제 파이프라인에서 “타입 가드 유틸을 매번 만들던 비용”을 크게 줄여줍니다. 특히 x != null 같은 nullish 제거, typeof/discriminated union 기반 분기처럼 자주 쓰는 패턴에서 효과가 좋습니다.

다만 filter(Boolean) 는 런타임 의미가 넓어서 데이터에 따라 버그를 만들 수 있고, 콜백이 복잡해지면 여전히 명시적 타입 가드가 필요합니다. TS 5.5의 기능을 최대한 활용하려면, 타입좁히기 조건을 단순하고 표준적인 형태로 유지하고, 필요하면 satisfies 로 타입 경계를 고정하는 습관이 도움이 됩니다.