EdgeCases Logo
Mar 2026
TypeScript
Expert
8 min read

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
type-predicates
typescript-5.5
type-guards
narrowing
filter

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 null

After 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 return statement (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 narrowed

When 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 number

The 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 predicate

Mutation 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 number

If 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 number

If 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); // ✅ Works

Debugging 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

Related Insights

Explore related edge cases and patterns

TypeScript
Deep
TypeScript NoInfer: Controlling Generic Inference
7 min
TypeScript
Expert
TypeScript infer: Advanced Extraction Patterns
8 min
TypeScript
Expert
TypeScript Distributive Conditional Types: The Union Distribution Rule
8 min

Advertisement