Programming

Ciro Sayagués Laso • 15 MAY 2023

A step-by-step guide to using Zod for backend data validation

post cover picture

As backend developers, we often work on API endpoints where we receive requests with certain information, and it's our responsibility to ensure that the provided data doesn't cause any issues. We validate the input and respond with a friendly message to the frontend if something fails, asking the user to try again. This can be achieved through different approaches. In this article, we'll investigate the use of Zod as a developer-friendly way to reduce errors and optimize the process. Let’s go for it!

 

How Input Data Validation works

Consider a scenario where we have a hotel management system and an endpoint to create a new hotel: POST /hotels. Here, we expect to receive the necessary information to create a hotel type like the following: 

 


type Room = {
 number: number;
 area: number;
 capacity: number;
};


type Hotel = {
 name: string;
 address?: string;
 rooms: Room[];
 country: string | null;
};

 

In order to have something with the "Hotel" type, we perform the required validations using something similar to:


if (!requestBody.name) throw new Error("The name is mandatory");
if (typeof requestBody.name !== "string")
 throw new Error("The name must be a string");
if (typeof requestBody.address !== "string")
 throw new Error("The address must be a string");
if (requestBody.country === undefined)
 throw new Error(
   "The country must be defined, please use null if the hotel has no country"
 );


requestBody.rooms?.forEach((room) => {
 if (!room.number) throw new Error("The number is mandatory in the rooms");
 if (!room.area) throw new Error("The area is mandatory in the rooms");
 if (!room.capacity) throw new Error("The capacity is mandatory in the rooms");
});

 

And finally, we are left with the desired type.


const hotel: Hotel = requestBody;

 

This approach can work and even be optimal and safe if we spend enough time implementing it correctly. However, there are some downsides that need to be considered: 

 

Zod: paving the way for data validation

Here’s when Zod comes in. In case you haven't heard about it, Zod is a schema declaration and validation library that focuses on making development easier and more efficient. Its concise, chainable interface and functional approach to parsing make it a versatile option for developers as it enhances the input data validation process.

How? 

  1. We can define a schema, and the validations are based on that schema. So, any changes to the schema will automatically modify the validations.
  2. We don’t have to define our validations, so we won’t have to spend time reviewing them.
  3. We can trust the effectiveness of the library, so there should be no surprises.

Anytime we can achieve the same outcome with Zod, we may opt to take this path. However, in this scenario, let's assume that the prior implementation is complete, and our task is to determine how to switch from one solution to another without introducing any errors. Let's see how.

 

Step 1: Creating Equivalent Schemas

Our initial course of action involves generating schemas that enable Zod to perform verifications to ensure the accuracy of received data.


import { z } from "zod";


const RoomSchema = z.object({
 number: z.number(),
 area: z.number(),
 capacity: z.number(),
});


const HotelSchema = z.object({
 name: z.string(),
 address: z.string().optional(),
 rooms: z.array(RoomSchema),
 country: z.string().nullable(),
});

 

When dealing with optional values (which can be undefined) and nullable values (which can be null), we need to exercise caution to ensure that our schema produces a type that is equivalent to the one we had previously. However, we have a means of verifying this after creating our schema, by utilizing Zod's infer type feature, which generates a type based on a Zod schema.

Additionally, we can use Zod's util.assertEqual function to compare the data type we had before with the type inferred from our schema.


z.util.assertEqual<Hotel, z.infer<typeof HotelSchema>>(true);

 

How do we know if it matches or not? When it doesn't match, it will display an error as shown below:


type Room = {
 number: number;
 area: number;
 capacity: number;
};


const RoomSchema = z.object({
 number: z.number(),
 area: z.number(),
 capacity: z.number(),
});
z.util.assertEqual<Room, z.infer<typeof RoomSchema>>(true);


type Hotel = {
 name: string;
 address: string;
 rooms: Room[];
 country: string | null;
};


const HotelSchema = z.object({
 name: z.string({
   required_error: "The name is mandatory",
   invalid_type_error: "The name must be a string",
 }),
 address: z
   .string({
     invalid_type_error: "The address must be a string",
   })
   .optional(),
 rooms: z.array(RoomSchema),
 country: z.string().nullable(),
});
z.util.assertEqual<Hotel, z.infer<typeof HotelSchema>>(true);

 

Can you tell what makes them different? If it's not immediately apparent, then the usefulness of the util.assertEqual function has been demonstrated. The difference is that we made the "address" property of the Hotel object mandatory! The schema specifies that it is optional, hence the error indicating a type mismatch.

As you might see, in the following line z.util.assertEqual<Hotel, z.infer<typeof HotelSchema>>(true); will appear this error:  Argument of type 'true' is not assignable to parameter of type 'false'. , showing that the types are no longer compatible.

If the assertion fails, and we are unsure whether the problem lies with the Room type specifically, we can once again employ util.assertEqual:

z.util.assertEqual<Room, z.infer<typeof RoomSchema>>(true);

 

Step 2: Eliminating Manual Validations

Say goodbye to manual validations! We can now eliminate all the if statements we created and use Zod's parse function instead. This function returns an object of our desired type if all validations pass, and throws an error otherwise.


const hotel: Hotel = HotelSchema.parse(requestBody);

 

Step 3: Cleaning Up

Once we are confident that everything is working correctly, we proceed to remove what we no longer need in our code: we can eliminate the types and infer them instead.


// type Room = {
//   number: number;
//   area: number;
//   capacity: number;
// };
type Room = z.util.assertEqual<Room, z.infer<typeof RoomSchema>>(true);

 

This is what will truly ensure that in case of a change (such as adding a field), we do not have to modify code in two different places. Even in our example, we will not specify the Room type since our HotelSchema will use our RoomSchema. Therefore, we will only leave this line if we plan on using the Room type elsewhere.

Since we rely only on the inferred types from our schemas, there is no longer a need to verify that the types match. Therefore, we can remove the lines where we utilized util.assertEqual. To maintain a user-friendly approach to validations, we can enhance error messages. By passing a JSON as a parameter to Zod types, we can indicate the accompanying text for errors resulting from each failed validation. In our example, the JSON would be:


const HotelSchema = z.object({
 name: z.string({
   required_error: "The name is mandatory",
   invalid_type_error: "The name must be a string",
 }),
 ...
});

 

Conclusion

Data validation is a crucial aspect of backend development that ensures the input data we receive doesn't cause any issues. While manual validation may work, it can be time-consuming and requires a lot of attention to detail. Zod can help mitigate these downsides by providing a way to define a schema that automatically modifies validations when the schema changes. Overall, Zod is an effective library that simplifies data validation and helps us focus on other aspects of backend development.

Finally, I’d like to share a Git repository where you can see all the above-mentioned concepts applied, and experiment with the validation of a given input so that you can try it out in practice. 

For more in-depth discussions on software development and related topics, feel free to explore our blog!

Stay updated!

project background