DEV Community

Gaurav Kumar Singh
Gaurav Kumar Singh

Posted on

A Beginner's Guide to Custom TypeScript Type Guards

You have learned the basics of TypeScript- interfaces, types, unions, and generics. Your code compiles cleanly, and your editor feels like magic.

Then, reality hits. You fetch data from an external API, read from localStorage, or handle user input. To TypeScript, all of that external data is any or unknown. The type checker has no idea what shape the data actually has. If you try to access response.data.user.id, TypeScript panics—and rightly so.

How do you safely bridge the gap between runtime uncertainty and compile-time safety? Type Guards.

1. What is a Type Guard?

At its core, a type guard is some expression or function that performs a runtime check and guarantees the type of a variable within a specific scope.

TypeScript provides built-in type guards out of the box:

  • typeof x === "string"
  • x instanceof Date
  • "prop" in obj

When TypeScript sees these checks inside an if block, it automatically narrows the type. But what happens when you have a custom business object, like a User, an Admin, or a nested API payload? Built-in guards aren't enough. That’s where user-defined (custom) type guards come in.

2. Writing Custom Type Guards: From Basic to Complex

A custom type guard is just a regular function with a very special return type: a type predicate using the is keyword (e.g., parameter is Type).

Level 1: The Basic Primitive Guard

Let’s start simple. Imagine you receive a value that could be a string or a number, but you want to ensure it's a specific format (like a non-empty trimmed string).

function isNonEmptyString(value: unknown): value is string {
  return typeof value === "string" && value.trim().length > 0;
}

function processInput(input: unknown) {
  if (isNonEmptyString(input)) {
    // TypeScript now knows `input` is strictly a `string` here!
    console.log(input.toUpperCase()); 
  } else {
    console.log("Invalid or empty input.");
  }
}
Enter fullscreen mode Exit fullscreen mode
  • The Magic Word: value is string is the type predicate. If the function returns true, TypeScript treats value as a string for the remainder of that scope.

Level 2: The Object Shape Guard

Real-world applications deal with objects. Let's say you have a User interface and want to verify that an incoming unknown object matches it.

interface User {
  id: number;
  name: string;
  email?: string; // Optional field
}

function isUser(candidate: unknown): candidate is User {
  return (
    typeof candidate === "object" &&
    candidate !== null &&
    "id" in candidate &&
    typeof (candidate as User).id === "number" &&
    "name" in candidate &&
    typeof (candidate as User).name === "string"
  );
}

// Usage in an API response handler
function handleApiResponse(response: unknown) {
  if (isUser(response)) {
    // Safe to access user properties!
    console.log(`Welcome back, ${response.name} (ID: ${response.id})`);
  } else {
    throw new Error("Malformed user payload!");
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Key takeaway: Notice how we first ensure candidate is an object and not null, check for property existence with in, and then validate individual primitive types.

Level 3: Complex & Nested Guards (Arrays & Unions)

As your applications grow, you might need to validate arrays of objects or complex union types. Here is how you can write a guard that validates an array of User objects:

function isUserArray(data: unknown): data is User[] {
  // First, check if it's an array
  if (!Array.isArray(data)) {
    return false;
  }

  // Every item in the array must pass our isUser guard
  return data.every(item => isUser(item));
}

// Usage
function renderUserList(payload: unknown) {
  if (isUserArray(payload)) {
    payload.forEach(user => console.log(user.name));
  }
}
Enter fullscreen mode Exit fullscreen mode

3. When Custom Guards Get Heavy: Use a Library

Writing manual type guards for dozens of complex, deeply nested models can become repetitive and error-prone. If you misspell a property name or forget a check in your manual guard, runtime bugs can still slip through.

When your application scales up and managing manual type guards feels like writing boilerplate, reach for a runtime validation library like Zod.

Meet Zod: Schema Declaration and Validation

Zod allows you to define schemas once, and it automatically generates both the TypeScript type and the runtime validation logic for you.

import { z } from "zod";

// 1. Define the schema
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email().optional(),
});

// 2. Automatically infer the TypeScript type!
type User = z.infer<typeof UserSchema>;

// 3. Validate at runtime with total safety
function handleApiResponse(response: unknown) {
  const result = UserSchema.safeParse(response);

  if (result.success) {
    // result.data is fully typed as User!
    console.log(result.data.name);
  } else {
    console.error("Validation failed:", result.error.format());
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary

  • Use manual type guards (value is Type) when you want zero external dependencies, lightweight checks, or when learning the mechanics of runtime narrowing.
  • Use Zod when your project grows complex, involves deep nesting, or requires strict parsing of untrusted payloads from APIs and forms.

Top comments (0)