TypeScript 5.5 Inferred Type Predicates — Stop Writing `x is T` By Hand

javascript dev.to

Before TypeScript 5.5, writing a type guard meant doing the compiler's job for it. You'd write the runtime check, then manually annotate the return type to tell TypeScript what it already could have figured out:

// Pre-5.5: you write the logic AND the annotation
function isString(x: unknown): x is string {
  return typeof x === 'string';
}

function isNonNull<T>(x: T | null): x is T {
  return x !== null;
}
Enter fullscreen mode Exit fullscreen mode

That x is T annotation is a lie waiting to happen. Refactor the function body, forget to update the return type, and your type guard silently becomes wrong. TypeScript trusted you. You lied.

TypeScript 5.5 fixes this. When a function's body is a type-narrowing expression, TS now infers the return type as a type predicate automatically. You write the logic. The compiler writes the annotation.

What Inferred Type Predicates Actually Look Like

With 5.5, this just works:

// TS 5.5: annotation inferred automatically
function isString(x: unknown) {
  return typeof x === 'string';
}

// Hover in VS Code: (x: unknown) => x is string
// No annotation needed.

const values: (string | number)[] = ['hello', 42, 'world', 7];
const strings = values.filter(isString);
//    ^? string[]  — not (string | number)[]
Enter fullscreen mode Exit fullscreen mode

That last line is the payoff. Before 5.5, filter(isString) returned (string | number)[] because the compiler didn't understand that isString was a type guard unless you annotated it explicitly. Now it does.

The Array Filtering Use Case (The One You Hit Every Day)

This is where inferred predicates earn their keep immediately. Filtering nulls out of arrays used to require a manual guard:

// Pre-5.5
function isNonNull<T>(x: T | null | undefined): x is T {
  return x != null;
}

const ids: (string | null)[] = ['abc', null, 'def', null, 'ghi'];
const validIds = ids.filter(isNonNull);
// validIds: string[]
Enter fullscreen mode Exit fullscreen mode

With 5.5, drop the annotation:

// 5.5
function isNonNull<T>(x: T | null | undefined) {
  return x != null;
}

const validIds = ids.filter(isNonNull);
// validIds: string[]  — same result, less ceremony
Enter fullscreen mode Exit fullscreen mode

But the real win is inline arrow functions in .filter(). This never worked before without an explicit cast:

// Pre-5.5: you had to do this awkward cast
const validIds = ids.filter((x): x is string => x !== null);

// 5.5: just write the condition
const validIds = ids.filter(x => x !== null);
//    ^? string[]  — inferred correctly
Enter fullscreen mode Exit fullscreen mode

Every codebase I've worked in had at least one filter(Boolean) cast somewhere that silently kept the wrong type. This closes that gap.

Discriminated Unions in Agent/API Code

I build AI agents at whoffagents.com, and discriminated unions are everywhere in that kind of code — agent responses, tool call results, event streams. Here's a realistic shape:

type AgentEvent =
  | { type: 'tool_call'; toolName: string; input: unknown }
  | { type: 'tool_result'; toolUseId: string; content: string }
  | { type: 'text'; content: string }
  | { type: 'error'; message: string; retryable: boolean };

// 5.5: no annotation needed
function isToolCall(event: AgentEvent) {
  return event.type === 'tool_call';
}

function isError(event: AgentEvent) {
  return event.type === 'error';
}

function isRetryable(event: AgentEvent) {
  return event.type === 'error' && event.retryable;
}
Enter fullscreen mode Exit fullscreen mode

Now filtering an event stream is clean:

async function processStream(events: AgentEvent[]) {
  const toolCalls = events.filter(isToolCall);
  //    ^? { type: 'tool_call'; toolName: string; input: unknown }[]

  const retryableErrors = events.filter(isRetryable);
  //    ^? { type: 'error'; message: string; retryable: boolean }[]

  for (const call of toolCalls) {
    console.log(call.toolName); // no cast, fully typed
  }
}
Enter fullscreen mode Exit fullscreen mode

Pre-5.5 you'd either annotate every one of those helpers or cast the filtered results. Either way it was friction that accumulated across hundreds of call sites.

Narrowing API Responses

SaaS apps spend half their life massaging external API responses into typed shapes. Inferred predicates fit naturally here:

type StripeEvent =
  | { type: 'payment_intent.succeeded'; data: { object: PaymentIntent } }
  | { type: 'customer.subscription.deleted'; data: { object: Subscription } }
  | { type: 'invoice.payment_failed'; data: { object: Invoice } };

// Route each event type to the right handler without manual annotation
function isPaymentSucceeded(event: StripeEvent) {
  return event.type === 'payment_intent.succeeded';
}

function isSubscriptionDeleted(event: StripeEvent) {
  return event.type === 'customer.subscription.deleted';
}

// In your webhook handler:
async function handleWebhook(events: StripeEvent[]) {
  await Promise.all([
    ...events.filter(isPaymentSucceeded).map(e => fulfillOrder(e.data.object)),
    ...events.filter(isSubscriptionDeleted).map(e => cancelSubscription(e.data.object)),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

Each filter result is the exact narrowed type — no as, no manual annotation, no drift risk.

The One Gotcha: Complexity Limits

Inferred type predicates only kick in when TypeScript can confidently analyze the function body. That means simple, direct conditions. The moment your predicate function has branching logic, early returns, or more than one condition that would need complex analysis, TS falls back to returning boolean.

// Works — simple equality check
function isText(event: AgentEvent) {
  return event.type === 'text';
}
// Inferred: (event: AgentEvent) => event is { type: 'text'; content: string }

// Does NOT infer a predicate — too complex
function isActionableEvent(event: AgentEvent) {
  if (event.type === 'error' && !event.retryable) return false;
  if (event.type === 'text') return false;
  return true;
}
// Inferred: (event: AgentEvent) => boolean  — no narrowing
Enter fullscreen mode Exit fullscreen mode

For complex guards you still need the explicit annotation. That's fine. The annotation is now the exception, not the rule.

Another edge case: if your function has a side effect before the return, TS may not infer the predicate:

// No predicate inferred — side effect breaks the pattern
function isStringWithLog(x: unknown) {
  console.log('checking', x);
  return typeof x === 'string';
}
Enter fullscreen mode Exit fullscreen mode

Solution: keep predicate functions pure and single-purpose. That's good practice regardless.

Before/After at Scale

Here's what a real utility file looked like before 5.5 in a TypeScript agent project:

// guards.ts — pre-5.5: 180 lines of manual annotations
export function isString(x: unknown): x is string {
  return typeof x === 'string';
}
export function isNumber(x: unknown): x is number {
  return typeof x === 'number';
}
export function isNonNull<T>(x: T | null | undefined): x is T {
  return x != null;
}
export function isError(x: AgentEvent): x is ErrorEvent {
  return x.type === 'error';
}
export function isToolCall(x: AgentEvent): x is ToolCallEvent {
  return x.type === 'tool_call';
}
// ... 20 more like this
Enter fullscreen mode Exit fullscreen mode

With 5.5:

// guards.ts — 5.5: drop every annotation
export function isString(x: unknown) { return typeof x === 'string'; }
export function isNumber(x: unknown) { return typeof x === 'number'; }
export function isNonNull<T>(x: T | null | undefined) { return x != null; }
export function isError(x: AgentEvent) { return x.type === 'error'; }
export function isToolCall(x: AgentEvent) { return x.type === 'tool_call'; }
Enter fullscreen mode Exit fullscreen mode

Same type safety. Half the lines. Zero drift risk from annotation/body mismatches.

Upgrading

You need TypeScript 5.5+. Check your version:

npx tsc --version
# Should be >= 5.5.0
Enter fullscreen mode Exit fullscreen mode

Update:

npm install typescript@latest --save-dev
# or
bun add -d typescript@latest
Enter fullscreen mode Exit fullscreen mode

No tsconfig changes required. It's enabled by default. You can start removing manual annotations from simple guards immediately — your existing annotated guards still work, they're just redundant now.

If you're building TypeScript-first AI agent infrastructure, this is one of those small features that compounds across a large codebase. I run into it constantly at whoffagents.com where event streams and tool call results flow through dozens of filter/map chains. Less annotation boilerplate means less surface area for bugs, and the compiler becomes a collaborator instead of someone you have to convince.

Source: dev.to

arrow_back Back to Tutorials