TypeScript 5.5 Inferred Type Predicates: When Inference Works (and Doesn't)
TypeScript 5.5 auto-infers type predicates from function bodies — but truthiness checks, multi-return, and parameter mutation block inference.
TypeScript 5.5 introduced a game-changing feature: automatic inference of type predicates.
Write x => x !== null, and TypeScript now infers (x) => x is NonNullable<T>.
But the inference has strict requirements. Understanding when it works — and when it doesn't —
explains why you still need manual type predicates in many cases.
The Breakthrough: filter() Finally Works
This was the most requested fix. Before 5.5:
const nums = [1, 2, null, 3].filter(x => x !== null);
// nums: (number | null)[] — filter didn't narrow!
nums.forEach(n => n.toFixed()); // Error: n might be nullAfter 5.5:
const nums = [1, 2, null, 3].filter(x => x !== null);
// nums: number[] — correctly narrowed!
nums.forEach(n => n.toFixed()); // ✅ Works
TypeScript infers that x => x !== null is a type predicate
(x: number | null) => x is number. The filter method's
type signature knows how to use this to narrow the array type.
The Inference Rules
TypeScript infers a type predicate when all of these hold:
- No explicit return type or type predicate annotation
- Single
returnstatement (no implicit returns via fallthrough) - Parameter is not mutated in the function body
- Return expression is a boolean tied to parameter narrowing
Let's see each rule in action.
Working Examples
// All of these get inferred type predicates:
// (x: unknown) => x is number
const isNumber = (x: unknown) => typeof x === 'number';
// (x: T) => x is NonNullable<T>
const isNonNullish = <T,>(x: T) => x != null;
// (bird: Bird | undefined) => bird is Bird
const isBird = (bird: Bird | undefined) => bird !== undefined;
// Even through other type guards!
declare function isString(x: unknown): x is string;
// (x: unknown) => x is string
const wrappedIsString = (x: unknown) => isString(x);Notice the last example: type predicates flow through wrapper functions. If your function returns the result of calling another type guard, the predicate is inferred transitively.
When Inference Fails: Truthiness Checks
Here's the most common gotcha:
const scores = [0, 85, 90, 0, 75].filter(score => !!score);
// scores: number[] — Wait, this is WRONG!TypeScript does not infer a type predicate here. Why? Because type predicates have "if and only if" semantics:
- If function returns
true→ parameter is the narrowed type - If function returns
false→ parameter is NOT the narrowed type
For !!score: if it returns false, score could be 0
(a valid number) or undefined. We can't say "score is not a number"
because 0 is a number! TypeScript correctly refuses to infer a predicate
that would be semantically wrong.
// ✅ This works — explicit null/undefined check
const scores = [0, 85, null, 90].filter(score => score !== null);
// scores: number[]
// ❌ This doesn't — truthiness can't distinguish 0 from null
const broken = [0, 85, null, 90].filter(score => !!score);
// broken: (number | null)[] — 0 gets filtered but type not narrowedWhen Inference Fails: Multiple Returns
// ❌ Multiple return statements block inference
function isPositive(x: number | null): boolean {
if (x === null) return false;
if (x <= 0) return false;
return true;
}
// No type predicate inferred — has 3 return statements
// ✅ Single return works
function isPositiveSingle(x: number | null) {
return x !== null && x > 0;
}
// (x: number | null) => x is numberThe single-return requirement keeps inference simple and predictable. Complex control flow with early returns requires explicit annotation.
When Inference Fails: Parameter Mutation
// ❌ Mutating parameter blocks inference
function normalize(value: string | string[]) {
if (Array.isArray(value)) {
value = value.join(','); // Mutation!
}
return typeof value === 'string';
}
// Just returns boolean, no type predicateMutation makes the control flow analysis ambiguous — the narrowed type might not match what the parameter was at function entry.
The Explicit Return Type Trap
Adding an explicit : boolean return type disables inference:
// ❌ Explicit boolean return type blocks inference
const isNumber = (x: unknown): boolean => typeof x === 'number';
// Just boolean, no type predicate
// ✅ Let TypeScript infer
const isNumber = (x: unknown) => typeof x === 'number';
// (x: unknown) => x is numberIf you've been explicitly typing all your functions (good practice normally!), you'll need to remove the return type to benefit from inference.
When Manual Predicates Are Still Needed
Inference handles simple cases. These patterns still need manual annotation:
1. Discriminated Unions with Type-Only Properties
type Result<T> = { ok: true; value: T } | { ok: false; error: Error };
// ❌ TypeScript can't infer predicate from discriminant alone
const isOk = (r: Result<unknown>) => r.ok;
// Returns boolean, not type predicate
// ✅ Manual annotation required
function isOk<T>(r: Result<T>): r is { ok: true; value: T } {
return r.ok;
}2. Property Existence Checks
type Cat = { meow(): void };
type Dog = { bark(): void };
// ❌ 'in' checks don't infer predicates
const isCat = (pet: Cat | Dog) => 'meow' in pet;
// Returns boolean
// ✅ Manual annotation
function isCat(pet: Cat | Dog): pet is Cat {
return 'meow' in pet;
}3. Complex Validation Logic
interface ValidForm {
name: string;
email: string;
age: number;
}
// ❌ Too complex for inference
const isValid = (form: Partial<ValidForm>) =>
!!form.name && !!form.email && typeof form.age === 'number';
// Returns boolean
// ✅ Manual predicate
function isValid(form: Partial<ValidForm>): form is ValidForm {
return !!form.name && !!form.email && typeof form.age === 'number';
}Breaking Change: More Precise Types
The new inference can narrow types more than you expect:
// Before TS 5.5:
const nums = [1, 2, null].filter(x => x !== null);
// (number | null)[]
nums.push(null); // ✅ Allowed
// After TS 5.5:
const nums = [1, 2, null].filter(x => x !== null);
// number[]
nums.push(null); // ❌ Error: null not assignable to numberIf your code relies on the wider type, add an explicit annotation:
const nums: (number | null)[] = [1, 2, null].filter(x => x !== null);
nums.push(null); // ✅ WorksDebugging Inference
Hover over a function in VS Code to see the inferred type. If you expected
a predicate but see boolean:
- Check for explicit return type annotations
- Check for multiple return statements
- Check for parameter mutation
- Check if truthiness check can't distinguish types (0, '', false)
Type predicate inference is a major quality-of-life improvement, eliminating boilerplate guards for common patterns. But the strict requirements mean you'll still write manual predicates for complex validation — and that's by design. When TypeScript can't prove your predicate is sound, it asks you to be explicit.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement