Appearance
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 })[] ✓ narrowedIf 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 optionalThis 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 automaticallyThe supported patterns are:
typeof x === ...checksx !== null/x !== undefined/x != nullArray.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 neededWe 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 complexThe 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.