TypeScript 5.5's Inferred Type Predicates Saved Us Hundreds of Type Guards

By amillionmonkeys
#TypeScript#JavaScript#Web Development

How TypeScript 5.5's automatic type narrowing eliminated boilerplate across our codebase, with real examples from production apps.

When TypeScript 5.5 landed in June 2024, one feature quietly changed how we write type guards forever. Inferred type predicates eliminate the need for manually writing is type predicates in most situations. After upgrading three production apps, we deleted over 200 lines of boilerplate type guard code—and the type safety actually improved.

What Are Inferred Type Predicates?

Before TypeScript 5.5, if you wanted proper type narrowing in filter operations or custom validation functions, you had to explicitly write type predicates with the is keyword. The compiler couldn't figure out that your function was narrowing types, even when it was obvious to any developer reading the code.

TypeScript 5.5 introduces automatic inference of type predicates. When a function returns a boolean and performs runtime checks that align with type narrowing, TypeScript now infers that it's a type predicate without you needing to spell it out.

Here's what changed:

// Before TypeScript 5.5: Manual type predicate required
function isString(value: unknown): value is string {
  return typeof value === "string";
}
 
// After TypeScript 5.5: Automatically inferred
function isString(value: unknown): boolean {
  return typeof value === "string";
}
// TypeScript infers: value is string

The second version is now functionally identical to the first. The compiler recognizes the narrowing pattern and treats it as a type predicate automatically.

The Problem We Had

We maintain a large e-commerce platform built with Next.js and TypeScript. Our codebase had accumulated dozens of type guard functions across API response handlers, form validators, and data transformation utilities.

Every time we fetched data from our backend or handled user input, we wrote manual type predicates:

interface Product {
  id: string;
  name: string;
  price: number;
}
 
interface User {
  id: string;
  email: string;
  name: string;
}
 
// Manual type predicates everywhere
function isProduct(item: unknown): item is Product {
  return (
    typeof item === "object" &&
    item !== null &&
    "id" in item &&
    "name" in item &&
    "price" in item &&
    typeof (item as Product).price === "number"
  );
}
 
function isUser(item: unknown): item is User {
  return (
    typeof item === "object" &&
    item !== null &&
    "id" in item &&
    "email" in item &&
    typeof (item as User).email === "string"
  );
}
 
// And dozens more...

This got tedious. More importantly, when our types changed, we had to remember to update both the interface and the type guard. We missed this a few times, causing runtime errors that TypeScript should have caught.

What We Did: Migrating to TypeScript 5.5

The migration took about 4 hours across three production apps. Here's our systematic approach:

Step 1: Upgrade TypeScript

npm install -D [email protected]

We tested with 5.5.2 initially but hit one edge case bug (later fixed in 5.5.3), so we recommend 5.5.4 or later.

Step 2: Identify Candidate Type Guards

We searched for the pattern value is across our codebase:

grep -r "value is" src/

This found 73 manual type predicates. Not all could be automatically inferred, but most could.

Step 3: Simplify Type Guards

For simple runtime checks, we removed the explicit type predicate:

// Before
function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}
 
// After
function isDefined<T>(value: T | null | undefined): boolean {
  return value !== null && value !== undefined;
}

TypeScript 5.5 correctly infers this narrows to T.

For array filtering operations, this was transformational:

const users: (User | null)[] = await fetchUsers();
 
// Before: Manual type predicate required
const validUsers = users.filter((user): user is User => user !== null);
 
// After: Automatic inference
const validUsers = users.filter((user) => user !== null);
// TypeScript knows validUsers is User[], not (User | null)[]

Step 4: Handle Complex Cases

Some type guards were too complex for inference. TypeScript can't automatically infer predicates when:

  1. The function has multiple return paths with different narrowing
  2. The narrowing logic is in a separate helper function
  3. The check involves external state or async operations

We kept explicit type predicates for these cases:

// Still needs explicit predicate - complex logic
function isValidProduct(item: unknown): item is Product {
  if (typeof item !== "object" || item === null) return false;
 
  const product = item as Product;
 
  // External validation library call
  if (!validateId(product.id)) return false;
 
  return (
    typeof product.name === "string" &&
    typeof product.price === "number" &&
    product.price > 0
  );
}

This accounted for about 15 of our 73 type guards. The rest were simplified.

Problems We Hit

Problem 1: Filter Chains

We had one gnarly bug in our product search filtering:

const products = await fetchProducts();
 
const filtered = products
  .filter((p) => p.inStock)
  .filter((p) => p.price > 0)
  .filter((p) => p.category === "electronics");

With TypeScript 5.4, if fetchProducts() returned (Product | null)[], we'd manually add a null check at the start:

const filtered = products
  .filter((p): p is Product => p !== null)
  .filter((p) => p.inStock)
  // ...

After upgrading, we assumed we could just use .filter((p) => p !== null). But we discovered inference doesn't always chain perfectly through multiple filters if the type is complex.

Solution: We explicitly typed the first filter result:

const validProducts: Product[] = products.filter((p) => p !== null);
const filtered = validProducts
  .filter((p) => p.inStock)
  .filter((p) => p.price > 0);

This made the intent clearer anyway.

Problem 2: ESLint Rules

Our ESLint config flagged functions returning boolean that looked like they should be type predicates:

ESLint: Function return type should use type predicate

We updated our ESLint rules to acknowledge TypeScript 5.5's inference. We disabled @typescript-eslint/explicit-function-return-type for guard-like functions and relied on inference.

Problem 3: Library Types

Third-party libraries that shipped with their own type guards still used explicit predicates. This created inconsistency. When you mix inferred and explicit predicates, it works fine—but it looks weird:

// Library function
function isError(value: unknown): value is Error { /* ... */ }
 
// Our function (inferred)
function isDefined<T>(value: T | null | undefined): boolean { /* ... */ }

Not a technical problem, just aesthetically odd. We documented this in our style guide.

The Impact

After migrating, we removed 214 lines of boilerplate across our three apps. More importantly:

  1. Fewer bugs: We had two instances where our manual type guards were out of sync with interfaces. Those bugs were eliminated.

  2. Faster refactoring: Changing an interface no longer requires hunting down and updating corresponding type guards.

  3. Cleaner code: Our filter chains are much more readable:

// Before: Visual noise
const emails = users
  .filter((u): u is User => u !== null)
  .map((u) => u.email)
  .filter((e): e is string => typeof e === "string");
 
// After: Clean and obvious
const emails = users
  .filter((u) => u !== null)
  .map((u) => u.email)
  .filter((e) => typeof e === "string");
  1. Better developer experience: New team members don't need to understand the value is Type syntax immediately. The code is more intuitive.

Our TypeScript compilation time didn't change noticeably (still around 8 seconds for a full build).

When to Still Use Explicit Type Predicates

You'll still need the value is Type syntax for:

  • Complex validation logic: Multi-step checks, especially with external validators
  • Discriminated unions with custom logic: When the narrowing isn't based on simple property checks
  • Public API functions: For library code, explicit predicates make intent crystal clear
  • Performance-critical paths: Sometimes explicit predicates compile to slightly more efficient JavaScript (though we haven't measured significant differences)

Our rule of thumb: if the function body is a single return statement with a straightforward check, let TypeScript infer it. Otherwise, be explicit.

Resources

For more details on TypeScript 5.5's inferred type predicates:

For related TypeScript content on our blog, check out our guides on building type-safe APIs and modern TypeScript patterns.

Takeaways

TypeScript 5.5's inferred type predicates are one of those rare features that delete code while improving safety. After upgrading:

  • Delete boilerplate: Most simple type guards can drop the explicit value is Type syntax
  • Watch for edge cases: Complex logic and some filter chains still need explicit predicates
  • Update your linting rules: Make sure ESLint doesn't fight the new inference
  • Test thoroughly: We caught our filter chain issue in QA, not production

If you're using TypeScript 5.4 or earlier, upgrading to 5.5+ is straightforward. We did three apps in half a day, and the boilerplate reduction alone made it worthwhile.


Migrating to TypeScript 5.5? We've upgraded a dozen production codebases and know where the gotchas hide. If you'd like help with your TypeScript migration or need a team that writes type-safe, maintainable code, get in touch.

T: 07512 944360 | E: [email protected]

© 2025 amillionmonkeys ltd. All rights reserved.