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
Dennis O'Keeffe
Melbourne, Australia
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