Emmanuel Akinfulubi

Lesson 13 of 1817 min read

Type Predicates and Assertions

Teaching the compiler something it cannot work out for itself, and the difference between proving a claim and simply insisting on it.

By the end: Write a type guard, and know when you are lying to the compiler.

Lesson 05 covered the checks the compiler already understands. This lesson is about the ones it does not — how to teach it something new, and the sharp distinction between proving a claim and merely insisting on one.

This is also the lesson that closes the biggest remaining hole in everything so far. Annotating a JSON response as User does not make it a User. Up to now we have been assuming our data is what we said it was. Here we stop assuming.

The problem: narrowing that does not survive a function

A helper the compiler learns nothing from

TS18046
function looksLikeUser(value: unknown): boolean {
  return typeof value === 'object' && value !== null && 'name' in value;
}

function greet(value: unknown): string {
  if (looksLikeUser(value)) {
    // The check passed. But `looksLikeUser` returns boolean, and a boolean
    // tells the compiler nothing about `value`. It is still unknown here.
    return `Hello ${value.name}`;
  }
  return 'Hello stranger';
}

console.log(greet({ name: 'Ada' }));

What the compiler says

error TS18046: 'value' is of type 'unknown'.

The fix: a type predicate

Change the return type from boolean to value is User and the function stops reporting a fact and starts making a claim the compiler will act on.

value is User, and the narrowing now propagates

interface User {
  name: string;
  age: number;
}

// The return type `value is User` says: if this returns true, treat the
// argument as a User from then on. It is still a boolean at run time.
function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'name' in value &&
    typeof (value as { name: unknown }).name === 'string' &&
    'age' in value &&
    typeof (value as { age: unknown }).age === 'number'
  );
}

function greet(value: unknown): string {
  if (isUser(value)) {
    // `value` is a User here, with full autocomplete and checking.
    return `Hello ${value.name}, aged ${value.age}`;
  }
  return 'Not a user';
}

console.log(greet({ name: 'Ada', age: 36 }));
console.log(greet({ name: 'Ada' }));
console.log(greet('nonsense'));
console.log(greet(null));

Output


      

The else branch narrows too

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function isCircle(shape: Shape): shape is { kind: 'circle'; radius: number } {
  return shape.kind === 'circle';
}

function area(shape: Shape): number {
  if (isCircle(shape)) return Math.PI * shape.radius ** 2;
  // Circle has been removed, so this is the square member and `side` is
  // available without any further check.
  return shape.side ** 2;
}

console.log(area({ kind: 'circle', radius: 1 }).toFixed(2));
console.log(area({ kind: 'square', side: 3 }));

Output


      

Predicates are unchecked promises

Here is the thing to be careful about. The compiler does not verify that your predicate's body actually establishes what its signature claims. It takes your word for it.

A predicate that lies, and is believed

interface User {
  name: string;
  age: number;
}

// This claims to check for a User and checks nothing at all. The compiler
// accepts the signature without examining the body.
function isUser(value: unknown): value is User {
  return true;
}

function greet(value: unknown): string {
  if (isUser(value)) {
    // The compiler is certain this is a User. It is a number.
    return `Hello ${value.name}, aged ${value.age.toFixed(0)}`;
  }
  return 'no';
}

try {
  console.log(greet(42));
} catch (error) {
  console.log('the lie caught up with us:', (error as Error).message);
}

Output


      

Assertion functions

A predicate returns a boolean you have to branch on. An assertion function throws instead, and narrows everything after the call. The keyword is asserts.

asserts value is T, for when absence is a bug

interface Config {
  host: string;
  port: number;
}

function assertConfig(value: unknown): asserts value is Config {
  if (typeof value !== 'object' || value === null) throw new Error('not an object');
  if (typeof (value as { host?: unknown }).host !== 'string') throw new Error('host must be a string');
  if (typeof (value as { port?: unknown }).port !== 'number') throw new Error('port must be a number');
}

function connect(raw: unknown): string {
  assertConfig(raw);
  // No if, no else. Past this line `raw` is a Config, or we never got here.
  return `${raw.host}:${raw.port}`;
}

console.log(connect({ host: 'localhost', port: 5432 }));

for (const bad of [null, { host: 'x' }, { host: 1, port: 2 }]) {
  try {
    connect(bad);
  } catch (error) {
    console.log('rejected:', (error as Error).message);
  }
}

Output


      

The simplest and most useful form: asserts a condition

// `asserts condition` narrows based on whatever expression you passed in.
function assert(condition: unknown, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

function firstChar(value: string | null): string {
  assert(value !== null, 'value must not be null');
  // `value` is string here, because the assertion narrowed the condition.
  assert(value.length > 0, 'value must not be empty');
  return value[0]!;
}

console.log(firstChar('Ada'));

for (const bad of [null, '']) {
  try {
    firstChar(bad);
  } catch (error) {
    console.log('rejected:', (error as Error).message);
  }
}

Output


      

as: the assertion with no check at all

as is the bluntest tool in the language. It changes what the compiler believes and does nothing whatsoever at run time.

as is pure compile-time fiction

interface User {
  name: string;
  age: number;
}

// No checking. No conversion. The compiler simply believes you.
const fromNowhere = { name: 'Ada' } as unknown as User;

console.log('name:', fromNowhere.name);
// The compiler is certain `age` is a number. It is undefined.
console.log('age: ', fromNowhere.age);
console.log('and arithmetic on it:', fromNowhere.age + 1);
What this compiles to
// No checking. No conversion. The compiler simply believes you.
const fromNowhere = { name: 'Ada' };
console.log('name:', fromNowhere.name);
// The compiler is certain `age` is a number. It is undefined.
console.log('age: ', fromNowhere.age);
console.log('and arithmetic on it:', fromNowhere.age + 1);

Output


      

Open the compiled output: the as is gone entirely. It generated no check, no coercion, nothing. That is the point to internalise — as is a note to the compiler, not an operation.

as will not let you claim anything at all

TS2352
const value = 'a string';

// TypeScript refuses assertions between types with no overlap, which catches
// the most obvious mistakes. It is a low bar, not a safety net.
const n = value as number;

console.log(n);

What the compiler says

error TS2352: Conversion of type 'string' to type 'number' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.

The three tools, side by side

Same goal, three different levels of honesty

interface User {
  name: string;
}

const raw: unknown = { name: 'Ada' };

// 1. as — no check. Fast, and wrong the moment the data changes.
const asserted = raw as User;
console.log('as:       ', asserted.name);

// 2. A predicate — a real check, and you branch on the result.
function isUser(value: unknown): value is User {
  return typeof value === 'object' && value !== null &&
    typeof (value as { name?: unknown }).name === 'string';
}
if (isUser(raw)) console.log('predicate:', raw.name);

// 3. An assertion function — a real check that throws, so the happy path
//    stays flat.
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) throw new Error('not a user');
}
assertUser(raw);
console.log('asserts:  ', raw.name);

Output


      

Validating real external data

Now the practical application, and the reason this lesson exists. Everything crossing into your program is unknown, and a predicate is how it becomes trustworthy.

A hand-written validator for a nested shape

interface Order {
  id: string;
  total: number;
  lines: { sku: string; quantity: number }[];
}

// Small, composable helpers. Each one is short enough to read and be sure of.
function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isOrderLine(value: unknown): value is { sku: string; quantity: number } {
  return isObject(value) &&
    typeof value['sku'] === 'string' &&
    typeof value['quantity'] === 'number';
}

function isOrder(value: unknown): value is Order {
  return isObject(value) &&
    typeof value['id'] === 'string' &&
    typeof value['total'] === 'number' &&
    Array.isArray(value['lines']) &&
    value['lines'].every(isOrderLine);
}

const inputs: string[] = [
  '{"id":"A-1","total":340,"lines":[{"sku":"desk","quantity":1}]}',
  '{"id":"A-2","total":"lots","lines":[]}',
  '{"id":"A-3","total":10,"lines":[{"sku":"chair"}]}',
  'null',
];

for (const json of inputs) {
  const parsed: unknown = JSON.parse(json);
  if (isOrder(parsed)) {
    const units = parsed.lines.reduce((n, line) => n + line.quantity, 0);
    console.log(`${parsed.id}: £${parsed.total}, ${units} units`);
  } else {
    console.log(`rejected: ${json.slice(0, 40)}`);
  }
}

Output


      

Where a validation library earns its place

The validator above is fifteen lines for one shape with one nested array. That does not scale, and hand-writing validators for thirty API types is a genuinely bad use of your time.

This is the one place in this course where reaching for a dependency is clearly right. Libraries in this category — Zod is the best known, and there are several good alternatives — let you declare the shape once and get both the runtime validator and the TypeScript type from that single declaration. That solves the duplication problem the hand-written approach has: an interface and a separate validator that must be kept in step.

The shape of the idea, in pseudocode

// Not a real library call, just the pattern every one of them uses.
//
//   const OrderSchema = object({
//     id: string(),
//     total: number(),
//     lines: array(object({ sku: string(), quantity: number() })),
//   });
//
//   type Order = Infer<typeof OrderSchema>;   // the type, derived
//   const order = OrderSchema.parse(json);    // the check, at run time
//
// One declaration, two outputs, and they cannot drift apart. That is the whole
// argument, and it is a good one.

Predicates in array methods

Where a predicate does real work in ordinary code

type Result =
  | { ok: true; value: number }
  | { ok: false; error: string };

const results: Result[] = [
  { ok: true, value: 10 },
  { ok: false, error: 'bad input' },
  { ok: true, value: 32 },
];

// A predicate lets filter narrow to the specific member, so `.value` is
// available afterwards without any further checking.
function isSuccess(r: Result): r is { ok: true; value: number } {
  return r.ok;
}

const succeeded = results.filter(isSuccess);
console.log('total:', succeeded.reduce((sum, r) => sum + r.value, 0));

function isFailure(r: Result): r is { ok: false; error: string } {
  return !r.ok;
}

console.log('errors:', results.filter(isFailure).map((r) => r.error));

// Lesson 07 showed that simple predicates are now inferred. A discriminant
// check is not one of those cases, so this is where you still write it out.

Output


      

A caution about over-trusting your own predicates

Checking the shape is not the same as checking the values

interface Age {
  years: number;
}

// This predicate is honest about the type and says nothing about the value.
function isAge(value: unknown): value is Age {
  return typeof value === 'object' && value !== null &&
    typeof (value as { years?: unknown }).years === 'number';
}

const inputs: unknown[] = [
  { years: 36 },
  { years: -5 },
  { years: NaN },
  { years: Infinity },
];

for (const input of inputs) {
  if (isAge(input)) {
    // All four pass the type check. Only the first is a sensible age.
    const sensible = Number.isFinite(input.years) && input.years >= 0;
    console.log(`years=${input.years}: type ok, sensible=${sensible}`);
  }
}

Output


      

Your turn

  1. Write isString(value: unknown): value is string and use it to filter an array of unknown down to a string[].
  2. Write the isObject helper returning value is Record<string, unknown>, then use it to write a predicate for a two-property shape without any as casts.
  3. Write a predicate that deliberately lies — returns true unconditionally — and confirm the compiler believes it. This is worth doing once so you take predicates seriously.
  4. Write function assert(condition: unknown, message: string): asserts condition and use it twice in one function to narrow a string | null and then check it is non-empty.
  5. Take a const object with two properties and assert it to an interface with three, using as unknown as. Then read the missing property and watch what happens.
  6. Write a predicate for a discriminated union member and use it with .filter(). Confirm you can reach the member-specific property afterwards.

Recap

  • A function returning value is T teaches the compiler a check it could not work out, and narrows in both branches.
  • A function returning asserts value is T throws instead of returning a boolean, which keeps the happy path free of nesting.
  • The compiler does not verify that a predicate's body proves its claim. A predicate is where you take responsibility, so keep the body short and test it.
  • as performs no check and emits no code. as unknown as T defeats the last remaining guard and deserves a comment explaining what you know.
  • Validate at the boundary, once. Hand-write predicates for a few shapes; use a schema library when there are many or they nest, so the type and the validator cannot drift apart.
  • A predicate establishes shape, not sanity. number includes NaN and -5, so value rules are a separate job.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.