Type-Safe Request Data Validation in Node.js with Zod and TypeScript

Featured on Hashnode
Type-Safe Request Data Validation in Node.js with Zod and TypeScript

In my journey as a developer, I've often found myself grappling with data validation in Node.js. While there are numerous libraries and techniques available, I always seemed to be making minor adjustments here and there to get things just right. That was until I discovered a function that not only streamlined the process but also leveraged TypeScript's static typing for enhanced safety and predictability. Today, I'm excited to share this function that has become a staple in all my projects.

The Power of Zod

Zod is a powerful tool for creating schemas and validating data. It's built with TypeScript in mind, meaning it has excellent TypeScript support out of the box. This makes it a great choice for TypeScript projects where you want to leverage the power of static typing to catch errors early.

The Magic Function

First, let's install Zod. I will be using pnpm, but you can use whatever package manager you want.

pnpm add zod

Below is the function I've been using for every one of my projects for quite a while now, taking advantage of a few cool features I'll explain in greater detail right after! I recommend placing it in a utils folder or file.

import { ZodObject, ZodRawShape, z } from "zod";

/**
 * Get and validate data provided in the request body of a backend route.
 *
 * @param req The Request object.
 * @param schema The Zod Schema to validate the data against.
 * @returns Promise resolving to the data if successful, or an error if not.
 */
export async function getAndValidateRequestData<
  T extends ZodObject<ZodRawShape>
>(
  request: Request,
  requestSchema: T
): /* The following return type enables TypeScript to show data or error as defined,
by checking for an undefined value of the other property. */
Promise<{ data: z.infer<T>; error: undefined } | { data: undefined; error: Error }> {
  try {
    const data = await request.json();

    /* Validate the data with zod. Not using requestSchema.safeParse(),
    since request.json() could throw an error as well. */
    requestSchema.parse(data);

    return { data, error: undefined };
  } catch (error: any) {
    return { data: undefined, error };
  }
}

Leveraging TypeScript Generics

The function utilizes TypeScript Generics, specifically T extends ZodObject<ZodRawShape>. This T represents the Zod schema that we pass to the function. By using this generictype T, we're able to infer the type of the data that the function returns. This means that the data returned by the function will automatically match the Zod schema passed to the function, providing us with type safety and auto-completion in our IDEs. This is a powerful feature that enhances the robustness and reliability of our data validation process.

Why This Return Type?

You might wonder why I'm using this particular return type:

Promise<{ data: z.infer<T>; error: undefined } | { data: undefined; error: Error }>

instead of for example this one:

Promise<{ data?: z.infer<T>; error?: Error }>

The reason is that it provides a very useful benefit in TypeScript. While the data and error returned from the function can initially both be undefined, the data will show as defined after checking for an error and vice versa. This is not the case with alternative syntaxes where data and error will always show up as possibly undefined.

Why Not Use safeParse?

Another question you might have when you've already used Zod in other projects is why I'm using schema.parse with a try-catch block instead of just using schema.safeParse. The reason is that req.json() could throw an error as well that would not be caught if I were just using safeParse.

Usage

I typically export the function from a utils folder and use it in my backend routes. Here's an example of how you might use it in a Next.js 13 API route:

import { z } from 'zod'
import { getAndValidateRequestData } from './utils'
import { NextResponse } from "next/server";

const userSchema = z.object({
  name: z.string(),
  email: z.string().email(),
})

export async function POST(req: Request) {
  try {
    /* Both data and error will be returned as possibly undefined */
    const { data, error } = await getAndValidateRequestData(req, userSchema)

    if (error) {
      console.error(error);
      return NextResponse.json({ error: error.message }, { status: 400 });
    }

    /* At this point, data will be defined,
    since we return early if an error occured. */

    /* The type of data will automatically match the Zod Schema due to TypeScript generics! */
    const { name, email } = data; 
    /* ... */
  } catch (error) {
    /* If a 500 error occures, we do not return the error directly back to the client,
    since it could include sensitive information from third-party APIs but rather just log it. */
    console.error(error)
    return NextResponse.json({}, { status: 500 });
  }
}

In this example, we define a Zod schema for a user, which expects an object with a name and an email. We then use getAndValidateRequestData to parse and validate the request data from our POST API route.

If an error occurs, we return a 400 response with the error message. If not, we can proceed to use the data returned by the function in our route which will automatically match the type of our userSchema. If an unexpected error occurs, we log the error and return a 500 response without exposing any sensitive information.

Wrapping Up

This approach to request data validation in Node.js is simple, yet powerful. It leverages the power of Zod and TypeScript to provide a robust and type-safe way to validate data. I hope you find it as useful as I have in my projects. Happy coding!