Skip to content
ExponaBLOG
JAVASCRIPT & TYPESCRIPT · DAY 048 / 100

Type guards and assertion functions: a step-by-step guide

Teach TypeScript what you know.

Expona Admin9 min read

Type guards and assertion functions

TypeScript only knows what you tell it. When you work with data from APIs, user input, or parsed JSON, the type system defaults to unknown or a broad union that doesn't match reality. Type guards and assertion functions are your tools to teach TypeScript what you know, narrowing types safely at the call site and catching runtime mismatches early.

Why guards matter in production

I've seen production bugs where a guard was missing just one field check. A handler received an object that looked like a Lead but was missing the email—the guard passed, the code failed at 3 AM. Guards are not just TypeScript niceties; they're runtime contracts that protect your system.

In e-commerce, CRM, and trading platforms, data flows through multiple services. A payment webhook might arrive with an optional field. A CRM contact could be partial. A trade confirmation might use legacy or new schemas. Guards let you handle this variation safely.

Type predicates — Narrowing with is

A type predicate is a function that returns x is Type. When the function returns true, TypeScript narrows the variable to that specific type in the calling scope.

typescript
interface Lead {
  id: string;
  email: string;
  name?: string;
}

function isLead(x: unknown): x is Lead {
  return typeof x === 'object' && x !== null
    && 'id' in x && typeof (x as any).id === 'string'
    && 'email' in x && typeof (x as any).email === 'string';
}

const data: unknown = JSON.parse(apiResponse);

if (isLead(data)) {
  // TypeScript now knows data is Lead
  console.log(data.email);
  saveToDatabase(data);
} else {
  console.error('Invalid lead shape');
}

Notice the guards check both the presence of fields and their types. A field existing in an object doesn't guarantee its type. The as any cast is unavoidable when accessing properties on unknown, but the real validation happens in the return type.

Type predicates are composable. Here's a more complex example from a ride-hailing backend:

typescript
interface Driver {
  id: string;
  license: string;
  vehicle: string;
  rating: number;
}

function isDriver(x: unknown): x is Driver {
  return typeof x === 'object' && x !== null
    && 'id' in x && typeof (x as any).id === 'string'
    && 'license' in x && typeof (x as any).license === 'string'
    && 'vehicle' in x && typeof (x as any).vehicle === 'string'
    && 'rating' in x && typeof (x as any).rating === 'number';
}

function isActiveDriver(x: unknown): x is Driver {
  return isDriver(x) && x.rating >= 4.0;
}

const potentialDriver: unknown = cache.get('driver:123');

if (isActiveDriver(potentialDriver)) {
  assignRide(potentialDriver);
}

The isActiveDriver predicate builds on isDriver, adding a business rule. After the check, TypeScript guarantees both the shape and the constraint.

Assertion functions — Fail fast with asserts

An assertion function doesn't return a boolean; it uses the asserts x is Type syntax. If the condition is false, it throws. If true, TypeScript narrows the type in all following code.

typescript
interface Order {
  id: string;
  total: number;
  customerId: string;
}

function assertIsOrder(x: unknown): asserts x is Order {
  if (typeof x !== 'object' || x === null) {
    throw new TypeError('Order must be an object');
  }
  if (!('id' in x) || typeof (x as any).id !== 'string') {
    throw new TypeError('Order.id must be a string');
  }
  if (!('total' in x) || typeof (x as any).total !== 'number') {
    throw new TypeError('Order.total must be a number');
  }
  if (!('customerId' in x) || typeof (x as any).customerId !== 'string') {
    throw new TypeError('Order.customerId must be a string');
  }
}

const webhookPayload: unknown = await parseWebhook(request);

assertIsOrder(webhookPayload);
// From here, webhookPayload is guaranteed to be Order
await processOrder(webhookPayload);

Use assertions when failure is exceptional. In a webhook handler, an invalid payload should crash fast rather than silently degrade. Use type predicates when you need to branch on validity.

A real pattern from a SaaS billing system:

typescript
interface PaymentMethod {
  id: string;
  type: 'card' | 'bank';
  verified: boolean;
}

function assertPaymentMethodVerified(x: unknown): asserts x is PaymentMethod & { verified: true } {
  if (typeof x !== 'object' || x === null) {
    throw new Error('Invalid payment method');
  }
  if ((x as any).verified !== true) {
    throw new Error('Payment method not verified');
  }
}

async function chargeAccount(pm: unknown) {
  assertPaymentMethodVerified(pm);
  // pm is now PaymentMethod & { verified: true }
  await stripe.charge(pm.id, amount);
}

Notice the intersection type in the assertion return: PaymentMethod & { verified: true }. This tells TypeScript that after the assertion, the verified property is definitely true, not just boolean.

Prefer schema validation — Zod over hand-written guards

Hand-written guards work, but they're verbose and easy to get wrong. Every field check is boilerplate. Every type change requires updating the guard. In my experience, the gap between the TypeScript interface and the guard is where bugs hide.

Zod is a schema validation library that generates both runtime validators and TypeScript types. Define once, validate everywhere:

typescript
import { z } from 'zod';

const LeadSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  name: z.string().optional(),
});

type Lead = z.infer;

const data: unknown = JSON.parse(apiResponse);

try {
  const lead = LeadSchema.parse(data);
  // lead is Lead, type and runtime validated
  saveToDatabase(lead);
} catch (err) {
  console.error('Invalid lead:', err.errors);
}

Zod handles edge cases automatically: trimming strings, coercing types, validating email format. It also provides better error messages. When parsing fails, you get structured feedback about which field and why.

For a travel booking system with complex nested schemas:

typescript
const BookingSchema = z.object({
  id: z.string().uuid(),
  userId: z.string(),
  flights: z.array(z.object({
    departure: z.string().datetime(),
    arrival: z.string().datetime(),
    airline: z.enum(['AA', 'UA', 'DL']),
  })),
  hotel: z.object({
    name: z.string(),
    nights: z.number().int().positive(),
    roomType: z.enum(['single', 'double', 'suite']),
  }).optional(),
  totalPrice: z.number().positive(),
});

type Booking = z.infer;

const apiResponse = await fetch('/bookings/123').then(r => r.json());
const booking = BookingSchema.parse(apiResponse);

Zod also handles custom validation. For a trading platform checking price limits:

typescript
const TradeSchema = z.object({
  symbol: z.string(),
  quantity: z.number().positive(),
  price: z.number().positive(),
  maxSlippage: z.number().min(0).max(0.1), // 0–10%
}).refine(
  (data) => data.price * data.quantity <= 1000000,
  { message: 'Order value exceeds limit', path: ['price'] }
);

Once you adopt Zod, hand-written guards become rare. You'll use them only for performance-critical hot paths where you've already validated with Zod and need a tiny type guard.

Keep guards honest — A wrong guard lies to the compiler

The worst guard is one that claims a type is valid but isn't. TypeScript trusts your guard completely. If isLead returns true but the data lacks an email, your code crashes at runtime and TypeScript can't help you.

Common mistakes:

  • Checking only shallow properties: typeof x === 'object' passes for arrays, null, and dates.
  • Forgetting type checks on values: 'email' in x passes, but you don't verify it's a string.
  • Missing required fields: A guard checks id and email but the interface also has role.
  • Not handling edge cases: Allowing empty strings, negative numbers, or invalid dates that the business logic rejects.

A safe pattern: use Zod for public API boundaries, and use type predicates only for internal functions where you control the input.

typescript
// At the API boundary: parse and validate strictly
const webhookData = WebhookSchema.parse(req.body);

// Inside your handler: you can use simpler checks because Zod already validated
function extractLeadEmails(orders: Order[]): string[] {
  return orders
    .filter(order => isLead(order.contact)) // simpler guard, input already vetted
    .map(order => order.contact.email);
}

Common mistakes

  • Trusting typeof for objects: typeof [] === 'object' and typeof null === 'object'. Always check x !== null and use Array.isArray() if needed.
  • Confusing predicates and assertions: Use x is Type when you might reject; use asserts x is Type when rejection is fatal.
  • Guards that silently fail: A guard checking only required fields misses optional fields that happen to be present but malformed. Prefer Zod which validates the full shape.
  • Hand-writing what Zod does: Zod handles coercion, trimming, and detailed errors. Unless you have a specific reason (performance, no external deps), use Zod.
  • Not updating guards when types change: Add a field to your interface? You must update the guard. This coupling is why schema libraries are safer.

Key takeaways

  • Type predicates (x is Type) narrow types in calling code without throwing; use them to branch on validity.
  • Assertion functions (asserts x is Type) throw if the condition fails; use them at system boundaries where invalid data is a fatal error.
  • Prefer Zod or similar schema validators over hand-written guards; they handle edge cases, coercion, and error messages automatically.
  • A wrong guard lies to TypeScript. Always validate both presence and type of fields; test your guards with invalid data.
  • Combine validation layers: strict Zod parsing at API boundaries, simpler guards for internal functions working with pre-validated data.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles