TypeScript Type Guards: narrowing and is Keyword

TypeScript's type system is powerful, but it has limitations. When you work with union types—variables that could be one of several types—TypeScript takes a conservative approach. It only allows you...

Key Insights

  • Type guards solve TypeScript’s inability to automatically narrow union types by providing explicit runtime checks that inform the compiler about type refinement
  • The is keyword creates user-defined type predicates that return boolean values while simultaneously narrowing types in the true branch of conditionals
  • Custom type guards are essential for discriminated unions, API responses, and any scenario where TypeScript’s built-in narrowing (typeof, instanceof) falls short

Introduction to Type Guards

TypeScript’s type system is powerful, but it has limitations. When you work with union types—variables that could be one of several types—TypeScript takes a conservative approach. It only allows you to access properties and methods that exist on all possible types. This is where type guards become essential.

Consider this common scenario:

interface User {
  id: string;
  name: string;
  email: string;
}

interface ApiError {
  code: number;
  message: string;
}

function handleResponse(response: User | ApiError) {
  console.log(response.email); // Error: Property 'email' does not exist on type 'ApiError'
}

TypeScript refuses to compile this code because ApiError doesn’t have an email property. You need a way to tell TypeScript which type you’re actually working with at runtime. That’s exactly what type guards do—they narrow the type from a union to a specific member of that union.

Built-in Type Narrowing Techniques

TypeScript provides several built-in mechanisms for type narrowing that work automatically without custom code.

The typeof operator works well for primitive types:

function processValue(value: string | number) {
  if (typeof value === "string") {
    // TypeScript knows value is a string here
    return value.toUpperCase();
  } else {
    // TypeScript knows value is a number here
    return value.toFixed(2);
  }
}

For class instances, use instanceof:

class NetworkError extends Error {
  constructor(public statusCode: number, message: string) {
    super(message);
  }
}

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

function handleError(error: NetworkError | ValidationError) {
  if (error instanceof NetworkError) {
    console.log(`Network error: ${error.statusCode}`);
  } else {
    console.log(`Invalid fields: ${error.fields.join(", ")}`);
  }
}

Truthiness checks provide another narrowing mechanism, particularly useful for optional properties:

interface Product {
  id: string;
  name: string;
  discount?: number;
}

function calculatePrice(product: Product, basePrice: number): number {
  if (product.discount) {
    // TypeScript narrows discount from 'number | undefined' to 'number'
    return basePrice * (1 - product.discount);
  }
  return basePrice;
}

These built-in techniques handle many common cases, but they break down with interfaces, type aliases, and complex discriminated unions.

Custom Type Guards with the is Keyword

When built-in narrowing isn’t sufficient, you create custom type guards using the is keyword. A type guard is a function that returns a type predicate—a boolean with type information attached.

The syntax is: parameter is Type. Here’s a basic example:

interface User {
  id: string;
  name: string;
  email: string;
}

interface ApiError {
  code: number;
  message: string;
}

function isUser(response: User | ApiError): response is User {
  return (response as User).email !== undefined;
}

function handleResponse(response: User | ApiError) {
  if (isUser(response)) {
    // TypeScript knows response is User
    console.log(response.email);
  } else {
    // TypeScript knows response is ApiError
    console.log(response.code);
  }
}

The magic happens in the return type response is User. This tells TypeScript: “If this function returns true, treat the parameter as a User in the calling code.”

Type guards work excellently for checking array element types:

interface Cat {
  type: "cat";
  meow(): void;
}

interface Dog {
  type: "dog";
  bark(): void;
}

type Pet = Cat | Dog;

function isCat(pet: Pet): pet is Cat {
  return pet.type === "cat";
}

function handlePets(pets: Pet[]) {
  const cats = pets.filter(isCat);
  // cats is now typed as Cat[], not Pet[]
  cats.forEach(cat => cat.meow());
}

For more complex scenarios, combine multiple conditions:

interface AdminUser {
  role: "admin";
  permissions: string[];
  adminLevel: number;
}

interface RegularUser {
  role: "user";
  permissions: string[];
}

type AnyUser = AdminUser | RegularUser;

function isAdminUser(user: AnyUser): user is AdminUser {
  return user.role === "admin" && "adminLevel" in user;
}

Type Guards for Discriminated Unions

Discriminated unions use a common property (the discriminant) with literal types to distinguish between union members. Type guards make working with them elegant.

interface ClickEvent {
  kind: "click";
  x: number;
  y: number;
}

interface KeyEvent {
  kind: "keypress";
  key: string;
  modifiers: string[];
}

interface FocusEvent {
  kind: "focus";
  elementId: string;
}

type UIEvent = ClickEvent | KeyEvent | FocusEvent;

function isClickEvent(event: UIEvent): event is ClickEvent {
  return event.kind === "click";
}

function isKeyEvent(event: UIEvent): event is KeyEvent {
  return event.kind === "keypress";
}

function handleEvent(event: UIEvent) {
  if (isClickEvent(event)) {
    console.log(`Clicked at ${event.x}, ${event.y}`);
  } else if (isKeyEvent(event)) {
    console.log(`Key pressed: ${event.key}`);
  } else {
    // TypeScript infers this must be FocusEvent
    console.log(`Focus on element: ${event.elementId}`);
  }
}

Often you don’t even need custom type guards for discriminated unions—direct property checks work:

function handleEventDirect(event: UIEvent) {
  if (event.kind === "click") {
    // TypeScript automatically narrows to ClickEvent
    console.log(`Clicked at ${event.x}, ${event.y}`);
  }
}

But custom type guards become valuable when you need reusable logic or complex validation.

Advanced Patterns and Best Practices

TypeScript 3.7 introduced assertion signatures using the asserts keyword. Unlike type guards that narrow types in conditional branches, assertion functions throw errors if the type doesn’t match:

function assertIsUser(response: unknown): asserts response is User {
  if (
    typeof response !== "object" ||
    response === null ||
    !("email" in response)
  ) {
    throw new Error("Not a valid User");
  }
}

function processResponse(response: unknown) {
  assertIsUser(response);
  // After this point, TypeScript knows response is User
  console.log(response.email);
}

You can compose type guards for cleaner code:

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

function isNonEmptyString(value: unknown): value is string {
  return isString(value) && value.length > 0;
}

Avoid this anti-pattern—type guards that don’t actually validate:

// BAD: Lies to TypeScript
function isUser(response: any): response is User {
  return true; // Always returns true, provides no safety
}

// GOOD: Actually checks the structure
function isUser(response: unknown): response is User {
  return (
    typeof response === "object" &&
    response !== null &&
    "id" in response &&
    "email" in response &&
    typeof (response as any).email === "string"
  );
}

Real-World Use Cases

Type guards shine in API response handling where you receive untyped data:

interface ApiSuccessResponse<T> {
  status: "success";
  data: T;
}

interface ApiErrorResponse {
  status: "error";
  error: {
    code: string;
    message: string;
  };
}

type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

function isSuccessResponse<T>(
  response: ApiResponse<T>
): response is ApiSuccessResponse<T> {
  return response.status === "success";
}

async function fetchUser(id: string): Promise<User> {
  const response: ApiResponse<User> = await fetch(`/api/users/${id}`).then(r =>
    r.json()
  );

  if (isSuccessResponse(response)) {
    return response.data;
  } else {
    throw new Error(`API Error: ${response.error.message}`);
  }
}

For nested data structures, compose type guards:

interface UserProfile {
  user: User;
  settings: {
    theme: "light" | "dark";
    notifications: boolean;
  };
}

function isUserSettings(value: unknown): value is UserProfile["settings"] {
  return (
    typeof value === "object" &&
    value !== null &&
    "theme" in value &&
    "notifications" in value
  );
}

function isUserProfile(value: unknown): value is UserProfile {
  return (
    typeof value === "object" &&
    value !== null &&
    "user" in value &&
    "settings" in value &&
    isUserSettings((value as any).settings)
  );
}

Type guards transform TypeScript from a purely compile-time tool into something that bridges runtime and compile-time safety. Master them, and you’ll write more robust code with better developer experience. The is keyword is small but mighty—it’s your gateway to type-safe runtime validation.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.