Back to home

Ensuring Type Safety With Type Guards

Published: Jun 16, 2023

Last updated: Jun 16, 2023

This post will is 4 of 20 for my series on intermediate-to-advance TypeScript tips.

All tips can be run on the TypeScript Playground.

Introduction

In today's post, we'll take a deep dive into a TypeScript feature that is pivotal in ensuring type safety: Type Guards. We'll learn how to implement custom type guards to validate complex API responses with deep object structures.

What Are Type Guards?

In TypeScript, a type guard is a check that we can perform in our code to narrow down the type of an object within a certain scope. This way, we can tell the compiler that certain types are being used at certain places, enabling us to write safer, more predictable code.

There are several built-in type guards in TypeScript, such as typeof and instanceof, but for more complex scenarios, we need the ability to define custom type guards.

Defining a Custom Type Guard

A custom type guard is essentially a function that returns a boolean value, and its return type is a type predicate.

Let's take a look at a basic custom type guard:

function isNumber(x: any): x is number { return typeof x === "number"; }

Here, x is number is the type predicate. If this function returns true, TypeScript knows that x is a number in any subsequent code.

If we had the following code:

const randomObj = { a: 2, b: "not a number", }; function checkPropertiesAreNumbers() { if (isNumber(randomObj.a) && isNumber(randomObj.b)) { return true; } throw new Error("Not all properties are numbers"); }

We can see that TypeScript will know that randomObj.a and randomObj.b are numbers within the if block. Given that randomObj.b is not a number, TypeScript will throw an error on the isNumber(randomObj.b) check.

Deep API Response Validation with Custom Type Guards

Now, let's get to the crux of the matter: validating complex API responses. Imagine an API response with multiple nested objects and arrays, and you need to ensure the data's structure is correct before working with it. For that, we'll create a custom type guard that checks each property's existence and type recursively.

Suppose we expect a response structure like this:

type ApiResponse = { user: { id: string; name: string; friends: { id: string; name: string; }[]; }; };

Let's write a custom type guard to verify the response:

function isApiResponse(response: unknown): response is ApiResponse { if ( typeof response === "object" && response !== null && // checking that the response is not null (response as ApiResponse).user && typeof (response as ApiResponse).user.id === "string" && typeof (response as ApiResponse).user.name === "string" && Array.isArray((response as ApiResponse).user.friends) && (response as ApiResponse).user.friends.every( (friend) => typeof friend === "object" && friend !== null && // checking that the friend is not null typeof friend.id === "string" && typeof friend.name === "string" ) ) { return true; } return false; }

In this type guard, we're recursively checking each property's type, making sure that every object and array exists and has the correct data types. Now, we can use this guard after calling our API:

fetch("https://api.example.com/user") .then((response) => response.json()) .then((data) => { if (isApiResponse(data)) { // TypeScript now knows the type of 'data' console.log(`User ID: ${data.user.id}`); console.log(`User Name: ${data.user.name}`); } else { console.error("Invalid API response"); } });

With the type guard in place, TypeScript will know the type of data inside the if block, preventing any unexpected errors due to malformed API responses.

Leveraging Zod for Complex Type Validation

Zod is a powerful TypeScript-friendly library for building and validating schemas. It makes complex type validations a lot easier and more elegant. Let's re-implement our ApiResponse validation using Zod:

import { z } from "zod"; const FriendSchema = z.object({ id: z.string(), name: z.string(), }); const UserSchema = z.object({ id: z.string(), name: z.string(), friends: z.array(FriendSchema), }); const ApiResponseSchema = z.object({ user: UserSchema, }); function isApiResponse(response: unknown): response is ApiResponse { const result = ApiResponseSchema.safeParse(response); if (result.success) { return true; } return false; }

With Zod, we break down our ApiResponse structure into smaller schemas, each handling its part of the validation. The safeParse function then validates the data against the schema and automatically handles the checks for us, making the validation code much more maintainable and readable.

If validation fails, safeParse also provides detailed error information which can be very useful for debugging. Now, you can handle complex nested validation scenarios in a very concise and structured way, making your code more predictable and easier to understand. This shows the power of Zod when combined with TypeScript for complex data validations.

You can explore the Zod library in more detail here.

Summary

Custom type guards are a powerful tool for enhancing type safety in TypeScript, particularly when dealing with complex data structures from external sources like APIs. By employing them, we can ensure that our applications are more robust, predictable, and less prone to runtime errors.

Zod is a library that can help us write more concise and structured code when dealing with complex data validations. It's a great tool to have in your TypeScript arsenal.

You can play around with the TypeScript examples (not the last Zod one) in the TypeScript Playground.

Resources and further reading

Photo credit: adrienconverse

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on Visibuild.

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.

Ensuring Type Safety With Type Guards

Introduction

Share this post