TypeScript Type Guards — Narrow Types Safely

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Type guards are functions that narrow a variable's type. They're essential for working with union types safely. A good type guard eliminates runtime errors and tells the type checker exactly what type a variable is.

typeof Guards

The simplest and most common type guard:

function processValue(value: string | number): void {
  // Before guard - type is 'string | number'
  if (typeof value === "string") {
    // After guard - type is 'string'
    console.log(value.toUpperCase());
  } else {
    // Type is 'number'
    console.log(value.toFixed(2));
  }
}

// Works with all primitive types
type Primitive = string | number | boolean | bigint | symbol | null | undefined;

function handlePrimitive(value: Primitive) {
  if (typeof value === "string") console.log(value.length);
  else if (typeof value === "number") console.log(value.toFixed());
  else if (typeof value === "boolean") console.log(!value);
  else if (typeof value === "symbol") console.log(value.description);
  else if (typeof value === "bigint") console.log(value.toString(16));
  else console.log(value); // null | undefined
}

instanceof Guards

Check if a value is an instance of a class or constructor:

class User {
  constructor(public id: number, public name: string) {}
}

class Admin extends User {
  constructor(id: number, name: string, public permissions: string[]) {
    super(id, name);
  }
}

function describe(entity: User | Admin | string): void {
  if (typeof entity === "string") {
    console.log(`String: ${entity}`);
  } else if (entity instanceof Admin) {
    console.log(`Admin ${entity.name} with permissions: ${entity.permissions.join(", ")}`);
  } else if (entity instanceof User) {
    console.log(`User ${entity.name}`);
  }
}

describe(new Admin(1, "Alice", ["read", "write"]));
describe(new User(2, "Bob"));
describe("test");

Custom Type Predicates

Create reusable type guard functions with the is keyword:

// Basic predicate
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function processArray(items: unknown[]): string[] {
  return items.filter(isString);
}

// Predicate for objects
interface User {
  id: number;
  name: string;
  email: string;
}

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

const data: unknown = JSON.parse('{"id": 1, "name": "Alice", "email": "alice@example.com"}');

if (isUser(data)) {
  console.log(`User: ${data.name} (${data.email})`);
}

// Predicate for discriminated unions
type Vehicle = { type: "car"; wheels: 4 } | { type: "motorcycle"; wheels: 2 };

function isCar(vehicle: Vehicle): vehicle is { type: "car"; wheels: 4 } {
  return vehicle.type === "car";
}

function getSeats(vehicle: Vehicle): number {
  if (isCar(vehicle)) {
    return 5; // car property narrowed
  }
  return 1; // motorcycle
}

Discriminated Unions

The most elegant pattern for complex types:

// Result type - success or error
type Result<T> =
  | { status: "success"; data: T }
  | { status: "error"; error: string };

function handleResult<T>(result: Result<T>): T | null {
  if (result.status === "success") {
    // Type is { status: "success"; data: T }
    return result.data;
  } else {
    // Type is { status: "error"; error: string }
    console.error(result.error);
    return null;
  }
}

// API response example
type ApiResponse<T> =
  | { ok: true; data: T }
  | { ok: false; error: string; code: number };

function processResponse<T>(response: ApiResponse<T>): void {
  if (response.ok) {
    console.log("Success:", response.data);
  } else {
    console.error(`Error ${response.code}: ${response.error}`);
  }
}

Advanced Narrowing

// Exhaustiveness checking
type Direction = "north" | "south" | "east" | "west";

function move(direction: Direction): void {
  switch (direction) {
    case "north":
      console.log("Moving north");
      break;
    case "south":
      console.log("Moving south");
      break;
    case "east":
      console.log("Moving east");
      break;
    case "west":
      console.log("Moving west");
      break;
    default:
      const exhaustive: never = direction; // Error if case missing
      console.log(exhaustive);
  }
}

// Assertion functions (TypeScript 3.7+)
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new Error("Value is not a string");
  }
}

function processString(value: unknown): void {
  assertIsString(value); // After this, type is string
  console.log(value.toUpperCase());
}

// Assertion with condition
function assertDefined<T>(value: T | undefined): asserts value is T {
  if (value === undefined) {
    throw new Error("Value is undefined");
  }
}

const maybeUser: User | undefined = fetchUser();
assertDefined(maybeUser);
console.log(maybeUser.name); // No error

Real-World Examples

JSON Validation

interface Product {
  id: number;
  name: string;
  price: number;
}

function isProduct(value: unknown): value is Product {
  if (typeof value !== "object" || value === null) return false;
  const obj = value as Record<string, unknown>;
  return (
    typeof obj.id === "number" &&
    typeof obj.name === "string" &&
    typeof obj.price === "number" &&
    obj.price > 0
  );
}

function loadProducts(json: string): Product[] {
  const data = JSON.parse(json);
  if (!Array.isArray(data)) throw new Error("Expected array");
  return data.filter(isProduct);
}

Event Handling

type Event =
  | { type: "user:created"; userId: number }
  | { type: "user:deleted"; userId: number }
  | { type: "post:published"; postId: number; authorId: number };

function handleEvent(event: Event): void {
  switch (event.type) {
    case "user:created":
      console.log(`User ${event.userId} created`);
      break;
    case "user:deleted":
      console.log(`User ${event.userId} deleted`);
      break;
    case "post:published":
      console.log(`Post ${event.postId} by user ${event.authorId}`);
      break;
  }
}

Error Handling

class ValidationError extends Error {
  constructor(public fields: string[]) {
    super("Validation failed");
  }
}

class DatabaseError extends Error {
  constructor(public code: string) {
    super("Database error");
  }
}

type AppError = ValidationError | DatabaseError | Error;

function handleError(error: AppError): void {
  if (error instanceof ValidationError) {
    console.log(`Validation errors: ${error.fields.join(", ")}`);
  } else if (error instanceof DatabaseError) {
    console.log(`DB Error (${error.code})`);
  } else {
    console.log(`Generic error: ${error.message}`);
  }
}

Performance Considerations

Type guards have no runtime performance cost — they're just conditional logic. However:

  1. Avoid repeated checks - store the narrowed type
  2. Use exhaustive checks - switch statements are fast
  3. Cache predicates - reuse guard functions
// Good
function process(items: (string | number)[]): void {
  const strings = items.filter(isString);
  const numbers = items.filter((x) => !isString(x));
  // Use strings and numbers
}

// Avoid
function process(items: (string | number)[]): void {
  items.forEach((item) => {
    if (isString(item)) console.log(item.toUpperCase());
    else console.log(item.toFixed());
  });
}

FAQ

Q: What's the difference between is and asserts? A: is narrows the type for the caller. asserts throws if the condition fails. Use is for filters, asserts for validations that must succeed.

Q: Can I use type guards with generic types? A: Yes, but you need to preserve generic information in your predicate. This is tricky — often runtime validation libraries like Zod handle this better.

Q: Should I always write type guards for JSON? A: For untrusted data (APIs, user input, files), yes. For internal data, no. Use runtime validation libraries for complex schemas.


Type guards are TypeScript's way of being pragmatic: sometimes you genuinely don't know a type at compile time. Guards let you prove it to the type checker safely. Master them and your code becomes both type-safe and resilient to runtime surprises.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro