skip to content

Type Predicates (x is T)

Functions whose return type is `x is T`, letting a boolean check narrow at every call site — including the classic `array.filter(isDefined)` trick. Interviewers ask you to write one and explain why a plain boolean return does not narrow.

part ofTypeScriptoverview, primer and where to startread it →
on this pageshow

questions

4

In TypeScript, a utility module exports `function isString(value: unknown): value is string`. What does the `value is string` return type mean, and what type does `value` have inside `if (isString(value)) { ... }`?

level: juniorimportance: must knowfreq 62%

answer

  1. the return type does the work
  2. boolean at runtime, more at compile time
  3. control-flow analysis reads the signature
  4. a true result narrows the argument

basics

~20 s

The return type value is string makes isString a user-defined type guard: it returns an ordinary boolean at runtime, but the compiler treats a true result as proof and narrows value to string inside the if-branch.

solid answer

~40 s

`value is string` is a **type predicate**. At runtime nothing special happens — the function returns `true` or `false` like any boolean function, and the annotation is erased from the emitted JavaScript. What it adds is a signal to control-flow analysis: wherever you call `isString(x)` and branch on the result, the compiler narrows *the argument you passed* to `string` in the true branch. So inside `if (isString(value))`, `value` has type `string` and `value.toUpperCase()` compiles even though the parameter was declared `unknown`. In the else branch the compiler subtracts `string` from the declared type when there is something to subtract — `string | number` becomes `number`, while `unknown` stays `unknown`. The point is reuse: you write the check once and every call site narrows.

code

typescript · 12 lines
typescript
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function describe(input: unknown): string {
  if (isString(input)) {
    return input.toUpperCase(); // input narrowed to string
  }
  return String(input); // input is still unknown here
}

console.log(describe("ok"), describe(42));

go deeper

for a junior

Be able to read the signature out loud: the return type names a parameter and a type, and a true result means the compiler treats that argument as that type inside the branch. Say plainly that it still returns a boolean.

for a middle

Explain why the narrowing has to live in the signature at all — control-flow analysis does not look inside a called function — and describe what both branches get, including that subtraction only happens for unions.

for a senior

Show where you place predicates in a real system: at untyped boundaries such as parsed JSON or worker messages, one per shape, so that no call site needs a type assertion to use the value.

for a principal

Own the policy question: which boundaries are allowed to hand out narrowed types at all, who writes those guards, and what review standard they are held to, since every predicate is a claim the compiler will never re-examine.

## Why a predicate exists at all TypeScript's checker narrows types by reading the *code* of a condition: it recognises shapes like a `typeof` comparison, an `instanceof` test, or a discriminant equality check, and refines the variable inside the branch. A hand-written helper hides that check behind a function call, and calls are opaque to this analysis — the checker does not inline the body to see what it proved. Only the helper's **signature** crosses the call boundary. A type predicate is the mechanism for putting the narrowing *into* the signature so it survives the call. ## Reading the signature ```ts function isString(value: unknown): value is string { return typeof value === "string"; } ``` The return-type position holds `parameterName is Type` instead of `boolean`. The name on the left must be a parameter of that same function; the type on the right is what the compiler will assume about the corresponding argument when the call evaluates to `true`. The body still has to return a boolean expression — a predicate function is an ordinary boolean function with a richer declared return type. ## What the call site does ```ts function describe(input: unknown) { if (isString(input)) { return input.toUpperCase(); // input: string } return String(input); // input: unknown } ``` Narrowing lands on the *argument expression*, not on the parameter. It works when the argument is a reference the checker can track: a variable, a `const`, or a property path such as `payload.name` — `if (isString(payload.name)) payload.name.trim()` compiles. It does not work when you pass a fresh expression like `isString(makeValue())`, because there is no reference left to narrow, and it resets if you reassign the variable afterwards. ## Both branches, not just the true one In the true branch the compiler keeps the parts of the declared type compatible with the asserted type. If the declared type is `unknown` or `any`, you simply get the asserted type; if it is a union, you get the members assignable to it. The false branch does the opposite where it can: it *subtracts*. ```ts declare function isString(v: string | number): v is string; function f(v: string | number) { if (isString(v)) { v; // string } else { v; // number — string was removed from the union } } ``` Subtraction only happens when the declared type is a union containing something to remove. Starting from `unknown`, the else branch is still `unknown`, because "not a string" is not a type TypeScript can name. Candidates often expect `never` there — that is wrong. ## Nothing of this exists at runtime Types are erased. The emitted JavaScript for `isString` is the same function with the annotations stripped: ```js function isString(value) { return typeof value === "string"; } ``` That has two consequences worth saying out loud in an interview. First, a predicate costs nothing: it is not a cast, not a conversion, not a reflective check the compiler bolts on. Second, the annotation is a *promise the compiler takes at face value* — the type layer trusts what the signature claims about the boolean the body returns. ## Where they earn their keep Any place a value arrives untyped and you need to prove its shape once and use it many times: the result of `JSON.parse` (typed `any`), a `catch` variable typed `unknown` under `useUnknownInCatchVariables`, a message from a worker or socket, or a wide union you want to split. Writing `as string` at each use site would type-assert without checking and would have to be repeated; a predicate performs the real runtime check in one place and hands the narrowing to every caller. ## Habits that keep them readable Name them `isX` so the call site reads like a condition. Keep the body to a single boolean expression. Declare the parameter as wide as callers actually need — `unknown` for values from outside the program, the union type when you are splitting a known union — since the asserted type must be compatible with the parameter's declared type.

  • Does anything about `isString` change in the JavaScript that TypeScript emits?
    No. Types are erased, so the emitted function is `function isString(value) { return typeof value === "string"; }` — a plain boolean function. The predicate exists only for the checker, which is why it costs nothing at runtime and why it cannot enforce anything there.
  • You call `isString(payload.name)` rather than passing a plain variable. Does the narrowing stick?
    Yes. TypeScript narrows references, and a property access path like `payload.name` is a reference it tracks, so `payload.name.trim()` compiles inside the branch. It does not work for a call result such as `isString(getName())`, because there is no reference to attach the narrowing to.
  • If the parameter were declared `string | number` instead of `unknown`, what would the else branch give you?
    `number`. When the declared type is a union, the false branch subtracts the asserted type from it. Starting from `unknown` there is nothing to subtract, so the else branch stays `unknown` — it never becomes `never`.

It is a signed receipt rather than an inspection: the function still just says yes or no, but the signature tells the compiler what a "yes" is allowed to be taken as proof of.

saying these in an interview costs you the question

  • Thinks any function returning boolean narrows its argument
  • Calls the predicate a runtime cast or conversion
  • Expects the check to appear in the emitted JavaScript
  • Says the else branch always narrows to never
  • Believes the predicate narrows the parameter rather than the caller's value

context

open as a page

In TypeScript, a helper is written as `function isString(value: string | number): boolean { return typeof value === 'string'; }`. Callers then do `if (isString(x)) { x.toUpperCase(); }` and the compiler rejects `x.toUpperCase()`. Why does the boolean return type not narrow, and what change fixes it?

level: middleimportance: must knowfreq 70%

basics

~20 s

A boolean return type says nothing about which argument was checked, and the checker never looks inside a called function. Declaring the return type as value is string turns the helper into a type predicate, so callers narrow.

open as a page

In TypeScript, given `const names: (string | undefined)[]`, what type does `names.filter(n => n !== undefined)` produce, and how do you guarantee the result is `string[]` regardless of the compiler version?

level: middleimportance: should knowfreq 55%

basics

~20 s

On TypeScript 5.5 and later the compiler infers a type predicate for that arrow and the result is string[]; on earlier versions it stays (string | undefined)[]. Passing a function declared with a value is string return type always gives string[].

open as a page

In TypeScript, what does a method whose declared return type is `this is Foo` do at call sites, and when would you model a check that way instead of exporting a standalone `value is Foo` guard?

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

A method returning this is Foo is a type predicate on the receiver: when it returns true, the object you called it on is narrowed to Foo for the rest of the branch. Reach for it when the object owns the state being checked.

open as a page