Skip to content
Languages·2026-05-24·5 min read

A small note on type-narrowing in TypeScript 5.5

TypeScript 5.5 added inferred type predicates. Most of what you read about it understates how much pattern-matching code you can now delete.

TypeScript 5.5 shipped in mid-2024 with what the release notes called, almost casually, "inferred type predicates." I read the announcement, nodded, and went back to whatever I was doing. It took me about six months to realize that this small change deletes a class of code I had been writing, by hand, in every project, for a decade.

Here is the change, in two snippets.

Before: handwritten predicates

If you wanted to filter an array and have the result narrow correctly, you used to need a type predicate spelled out as a return type:

typescript
type User = { id: string; email?: string }

const isVerified = (u: User): u is User & { email: string } =>
  typeof u.email === 'string'

const verified = users.filter(isVerified)
// verified: (User & { email: string })[]   ✓ narrowed

If you forgot the predicate, the compiler dropped the narrowing on the floor:

typescript
const verified = users.filter(u => typeof u.email === 'string')
// verified: User[]   ✗ no narrowing, email is still optional

This was annoying enough that I had a snippet for it. It is also a real bug-magnet: the body of the predicate and the return type can disagree, and the compiler does not check that they line up.

After: inferred from the body

In TypeScript 5.5, the inline arrow form also narrows, because the compiler now infers the predicate when the callback's body is structurally one of the supported patterns:

typescript
const verified = users.filter(u => typeof u.email === 'string')
// verified: (User & { email: string })[]   ✓ narrowed automatically

The supported patterns are:

  • typeof x === ... checks
  • x !== null / x !== undefined / x != null
  • Array.isArray(x) and similar narrowing built-ins
  • A direct return of a single-call predicate

That last one is the lever. It means you can compose narrowing without losing it:

typescript
const isEmailVerified = (u: User) => typeof u.email === 'string'
const isAdminVerified = (u: User) =>
  isEmailVerified(u) && (u as any).role === 'admin'

const v = users.filter(isEmailVerified)
// v: (User & { email: string })[]   ✓

isEmailVerified has no explicit predicate. The compiler inferred one because its body matches a known pattern. You get narrowing for the price of a regular boolean function.

A real refactor

Here is a snippet from a webhook router we shipped last week. Old version, with handwritten predicates and a type guard helper:

typescript
type Event =
  | { kind: 'purchase'; cents: number }
  | { kind: 'refund'; cents: number; original: string }
  | { kind: 'subscription'; plan: string }

function isPurchase(e: Event): e is Extract<Event, { kind: 'purchase' }> {
  return e.kind === 'purchase'
}
function isRefund(e: Event): e is Extract<Event, { kind: 'refund' }> {
  return e.kind === 'refund'
}

const purchases = events.filter(isPurchase)
const refunds   = events.filter(isRefund)

New version, after the upgrade:

typescript
type Event =
  | { kind: 'purchase'; cents: number }
  | { kind: 'refund'; cents: number; original: string }
  | { kind: 'subscription'; plan: string }

const purchases = events.filter(e => e.kind === 'purchase')
const refunds   = events.filter(e => e.kind === 'refund')
//    ^ both are correctly narrowed; no helpers needed

We deleted about 90 lines of predicate helpers across the codebase. The compiler is doing the same work; it is just no longer asking us to write it down twice.

Where it still fails

Two cases caught us out. First, the predicate inference is local: if the callback delegates to a function defined elsewhere whose body is not itself a single supported pattern, you do not get narrowing.

typescript
const looksValid = (u: User) => {
  const trimmed = u.email?.trim()  // not a recognized narrowing pattern
  return typeof trimmed === 'string' && trimmed.length > 0
}

const v = users.filter(looksValid)
// v: User[]   ✗ no narrowing — body is too complex

The fix is to spell out the predicate by hand for that helper. The 5.5 change is opt-in when the compiler can prove it; it does not bend over backwards.

Second, the inference does not cross await. If your filter is async, you are back to writing predicates the old way, or doing the narrowing inside the loop with a manual cast at the end.

The takeaway

It is a small feature. It deletes a small amount of code. But it deletes the kind of code that is most likely to drift out of sync with the types it is supposedly guarding, so the impact on bug-density is larger than the line count suggests. If you are on TypeScript 5.4 or earlier and have a lot of .filter(isX)-style code, the upgrade is genuinely worth a slow afternoon.

For the official summary, see the TypeScript 5.5 release notes. The whole document is short, but the inferred-predicate section is the one I would re-read.