loke.dev
Header image for The Inferred Predicate

The Inferred Predicate

Stop writing manual type guards for your filter functions; TypeScript 5.5 finally understands your logic as deeply as you do.

· 3 min read

Consider this incredibly common scenario that has haunted TypeScript developers for years: you have an array of mixed types, you filter out the nulls, and TypeScript still insists the null might be there.

const mixedBag = ["apple", "banana", null, "cherry", undefined];

// In TypeScript 5.4 and below:
// filtered is still (string | null | undefined)[]
const filtered = mixedBag.filter(item => item !== null && item !== undefined);

// This would error: "Object is possibly 'null'"
filtered.map(item => item.toUpperCase()); 

Before TypeScript 5.5, the compiler wasn't quite "smart" enough to look inside that arrow function and realize that if it returns true, the item is guaranteed to be a string. We had to pay the "Type Guard Tax"—writing manual type predicates that felt like we were repeating ourselves to a very stubborn assistant.

The Old Way: Manual Labor

To get the code above to work previously, you'd have to write a custom type guard. It looked something like this:

const isNotNullish = <T>(value: T): value is NonNullable<T> => {
  return value !== null && value !== undefined;
};

const fixed = mixedBag.filter(isNotNullish);
// Now 'fixed' is string[]

It works, but it’s annoying. You're essentially telling TypeScript: "Trust me, I checked." The danger here is that you could accidentally lie to the compiler. If you changed the logic inside isNotNullish but forgot to update the value is NonNullable<T> return type, you'd end up with runtime crashes and a very confused IDE.

Enter Inferred Predicates

TypeScript 5.5 finally changed the game. The compiler now performs a control-flow analysis on your functions to see if they *could* be type guards. If the function meets certain criteria, TypeScript effectively writes the is T for you.

Let's look at a more complex example. Say you’re dealing with user roles:

type User = { name: string; role: 'admin' | 'guest' | 'editor' };

const users: User[] = [
  { name: "Alice", role: "admin" },
  { name: "Bob", role: "guest" },
  { name: "Charlie", role: "admin" }
];

// In TS 5.5, this is automatically inferred as (user is { role: 'admin' } & User)
const admins = users.filter(u => u.role === 'admin');

// No casting needed, no manual guards. Just vibes and logic.
console.log(admins[0].role); // Type is strictly 'admin'

I love this because it rewards you for writing plain, readable JavaScript. You don't have to learn a specific "TypeScript way" to filter an array; you just write the logic, and the compiler catches up.

Why did this take so long?

It sounds simple, but the engineering behind it is actually pretty wild. The compiler has to ensure your function is "pure" enough that the check is reliable.

For a function to have its return type inferred as a type predicate, it generally needs to follow these rules:
1. It must return a boolean.
2. It must have a clear "truthy" path that narrows the type.
3. It shouldn't have side effects that complicate the narrowing.

If I write a function that's too ambiguous, TypeScript will just shrug and treat it as a regular boolean function:

// This won't infer a predicate because it's not narrowing the type
const isLongString = (val: unknown) => typeof val === 'string' && val.length > 5;

const items: (string | number)[] = ["short", 123, "very long string"];
const longOnes = items.filter(isLongString); 
// longOnes is still (string | number)[] because 'isLongString' 
// doesn't prove it's NOT a number if it returns false.

The "Lying" Guard Problem

One of my favorite side effects of this update is that it makes your code safer by discouraging manual type guards. I've seen (and written) plenty of bugs like this:

function isString(val: any): val is string {
  return typeof val === 'number'; // Whoops, copy-paste error!
}

The compiler won't complain about the logic inside that function because you used the is keyword—you told it you knew better. With inferred predicates, you don't use the is keyword, so TypeScript actually verifies the logic. If the logic doesn't narrow the type, it just doesn't narrow the type. No lies, no "trust me" hand-waving.

When should you still use manual guards?

Inferred predicates are great for 90% of cases, but they aren't magic. If your narrowing logic is split across multiple functions or relies on complex hidden state, the compiler might give up.

Also, if you are building a library and want to be explicitly clear about your API's behavior, a manual type predicate acts as a contract. It tells the consumer: "I guarantee this function behaves as a guard."

But for your daily .filter(), .find(), and .every() calls? Throw those manual guards away. TypeScript 5.5 finally understands your logic as deeply as you do. It’s one less piece of boilerplate between you and a clean, type-safe codebase.