Control-flow analysis is generous inside a function and amnesiac at its edges. Write typeof x === "string" and the compiler narrows x for the rest of the block. Move that same check behind an isString(x) helper and the knowledge stays trapped in the callee. The call site sees a function returning boolean, learns nothing, and x is as wide as ever. The narrowing was real, but it did not survive the return.

Type predicates and assertion functions are the two escape hatches. They let a function hand its narrowing back to the caller, encoding in the signature a fact the compiler would otherwise have to rediscover. The price is that the compiler stops checking and starts believing.

A predicate is a boolean that narrows

A user-defined type guard is a function whose return type is x is T instead of boolean. The runtime value is still an ordinary boolean. The annotation tells control-flow analysis that a true result means the argument was a T, and a false result means it was not.

type Cat = { meow(): void };
type Dog = { bark(): void };

function isCat(animal: Cat | Dog): animal is Cat {
  return "meow" in animal;
}

declare const pet: Cat | Dog;

if (isCat(pet)) {
  pet.meow();   // pet: Cat
} else {
  pet.bark();   // pet: Dog, narrowed by the false branch too
}

The predicate names a parameter, so it composes with every place control flow already understands: if, ternaries, && chains, early returns. The quietest win is Array.prototype.filter, which has an overload keyed on guards. A plain predicate leaves the element type untouched. A guard rewrites it.

const mixed: (string | undefined)[] = ["a", undefined, "b"];

const looseFilter = mixed.filter((x) => x !== undefined);
// looseFilter: (string | undefined)[], the inequality is invisible to the type

function isDefined<A>(x: A | undefined): x is A {
  return x !== undefined;
}

const tight = mixed.filter(isDefined);
// tight: string[], the guard's `x is A` drives the filter overload

The first call narrows nothing because filter’s default signature returns the same array type it received. The second resolves to filter<S extends A>(p: (x: A) => x is S): S[], so undefined drops from the result. This is the standard fix for the Array<T | null> problem that flatMap and manual reduces are usually pressed into solving.

Assertions narrow by not returning

A predicate makes you branch. Sometimes you want the opposite: assert a fact and let execution proceed only if it holds. TypeScript 3.7 added assertion functions for this, signed with asserts instead of a return type.

function assertIsString(x: unknown): asserts x is string {
  if (typeof x !== "string") {
    throw new TypeError(`expected string, got ${typeof x}`);
  }
}

function shout(value: unknown) {
  assertIsString(value);
  // value: string, narrowed for the rest of the function
  return value.toUpperCase();
}

After the call, value is string on every path, because any other path threw. There is no if. The narrowing applies to the linear code that follows. An assertion function must return void (or never return at all) and must either throw or fall through.

The bare form drops the is T and narrows on a condition rather than a specific variable.

function assert(condition: unknown, message?: string): asserts condition {
  if (!condition) throw new Error(message ?? "assertion failed");
}

function head<T>(xs: T[]): T {
  assert(xs.length > 0, "empty array");
  return xs[0];           // T, and we have promised it exists at runtime
}

asserts condition tells the compiler that condition is truthy afterward, applying the same narrowing a successful if (condition) would. It is the runtime cousin of the never exhaustiveness check. Both encode “if we reach here, this is impossible” into a place the type system reads.

One signature wrinkle: a function variable cannot have an assertion type inferred. If you store an assertion behind a const, annotate it explicitly, or the asserts is lost.

The compiler trusts the body it never read

Here is the catch that makes both features sharp instruments. The compiler does not verify that a guard’s body proves its predicate, nor that an assertion’s body throws on the bad path. It reads the signature and believes it.

function isString(x: unknown): x is string {
  return typeof x === "number"; // wrong, and accepted without complaint
}

declare const u: unknown;
if (isString(u)) {
  u.toUpperCase(); // u: string, compiles, throws at runtime when u is a number
}

The body and the predicate contradict each other, yet the type checker raises nothing. A user-defined guard is an unchecked cast wearing a boolean’s clothes. The same hole exists for assertions: an asserts x is T whose body never throws hands the caller a T that was never validated. You own the soundness, the way you own a <T> cast or a non-null !.

Contrast this with the built-in guards. typeof x === "string", x instanceof Date, "id" in x, and Array.isArray(x) are narrowed by the compiler’s own model of what those operators do at runtime, so the narrowing they produce is sound by construction. Keep custom guards thin. Let them wrap built-in checks rather than reimplement them, so the surface you can get wrong stays small.

this is T for fluent classes

The same predicate machinery applies to a method’s receiver. A method returning this is T narrows the instance it was called on, which is how a class models internal state transitions for its callers.

class Box<T> {
  constructor(private value: T | null) {}
  hasValue(): this is Box<T> & { value: T } {
    return this.value !== null;
  }
}

declare const box: Box<number>;
if (box.hasValue()) {
  box.value.toFixed(2); // value: number, not number | null
}

Validators are predicates that survive into runtime

A type predicate is the exact runtime mirror of a type-level check. Where a conditional type answers a question about a type, a guard answers the same question about a value and reports the answer back in a form the compiler accepts.

Bracket balancing makes the parallel concrete. The valid-parentheses experiment decides balance entirely in the type system. The runtime version is an ordinary stack walk, and dressing it as a predicate lets a balanced string narrow to a branded subtype.

type Balanced = string & { readonly __balanced: unique symbol };

function isBalanced(s: string): s is Balanced {
  const stack: string[] = [];
  const close: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
  for (const ch of s) {
    if (ch === "(" || ch === "[" || ch === "{") stack.push(ch);
    else if (ch in close && stack.pop() !== close[ch]) return false;
  }
  return stack.length === 0;
}

declare const input: string;
if (isBalanced(input)) {
  // input: Balanced, only checked strings reach here
}

The Balanced brand turns the guard into a parser in the branded types sense. It produces a value the type system distinguishes from an arbitrary string, and the only way to mint one is to pass the check. Anywhere downstream that demands Balanced is now guaranteed a validated string, with the validation pinned to a single audited function instead of scattered ifs.

That is the whole shape of the technique. Narrowing is cheap inside a function and expensive to recover at a boundary, so you spend one careful function to recover it, and the compiler propagates the result everywhere for free. Keep those functions honest and thin, because they are the points where the type system stops proving and starts trusting. For the branch-by-branch discipline that pairs with predicates, see discriminated unions and exhaustiveness.