DEV Community

Cover image for Schema Validation with Zod and Express.js
Francisco Mendes
Francisco Mendes

Posted on

Schema Validation with Zod and Express.js

Overview

In the past I've done articles on how we can use libraries like Joi and Yup to create middleware that does input validation coming from the frontend.

Although both libraries are similar, they end up having a small difference in their implementation. But if you are going to make the transition from JavaScript to TypeScript it doesn't have any problems, because the only thing you need to do is install the data type dependencies and then infer them in the code.

However most libraries are JavaScript oriented, I don't mention this point as a negative aspect, but there are libraries which are TypeScript first and very easy to use.

That's why I'm talking about Zod, if you've already tried Yup or if you already have some experience, you'll literally feel at home because the API's are similar. The only thing that changes is that Zod has many more features for TypeScript developers.

Today's example

Today I'm going to do as in other articles where we proceeded to create a middleware to perform the schema validation of a specific route. The only difference is that we are going to create an API in TypeScript.

The idea is quite simple, let's create a middleware that will receive a schema as a single argument and then validate it.

Project setup

As a first step, create a project directory and navigate into it:

mkdir zod-example
cd zod-example
Enter fullscreen mode Exit fullscreen mode

Next, initialize a TypeScript project and add the necessary dependencies:

npm init -y
npm install typescript ts-node-dev @types/node --save-dev
Enter fullscreen mode Exit fullscreen mode

Next, create a tsconfig.json file and add the following configuration to it:

{
  "compilerOptions": {
    "sourceMap": true,
    "outDir": "dist",
    "strict": true,
    "lib": ["esnext"],
    "esModuleInterop": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Now let's add the following script to our package.json file.

{
  // ...
  "type": "module",
  "scripts": {
    "start": "ts-node-dev main.ts"
  },
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now proceed with the installation of the Express and Zod dependencies (as well as their development dependencies):

npm i express zod --save
npm i @types/express --save-dev
Enter fullscreen mode Exit fullscreen mode

Let's code

And now let's create a simple API:

// @/main.ts
import express, { Request, Response } from "express";

const app = express();

app.use(express.json());

app.get("/", (req: Request, res: Response): Response => {
  return res.json({ message: "Validation with Zod 👊" });
});

const start = (): void => {
  try {
    app.listen(3333, () => {
      console.log("Server started on port 3333");
    });
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
};
start();
Enter fullscreen mode Exit fullscreen mode

For the API to be initialized on port 3333 just run the following command:

npm start
Enter fullscreen mode Exit fullscreen mode

Now we can start working with Zod and first let's define our schema, in this example we will only validate the response body. And let's hope the body contains two properties, the fullName and the email. This way:

// @/main.ts
import express, { Request, Response } from "express";
import { z } from "zod";

const app = express();

app.use(express.json());

const dataSchema = z.object({
  body: z.object({
    fullName: z.string({
      required_error: "Full name is required",
    }),
    email: z
      .string({
        required_error: "Email is required",
      })
      .email("Not a valid email"),
  }),
});

// ...
Enter fullscreen mode Exit fullscreen mode

Now we can create our middleware, but first we have to import NextFunction from Express and AnyZodObject from Zod. Then let's call our middleware validate and receive schema validation in the arguments. Finally, if it is properly filled in, we will go to the controller, otherwise we will send an error message to the user.

import express, { Request, Response, NextFunction } from "express";
import { z, AnyZodObject } from "zod";

// ...

const validate = (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      return next();
    } catch (error) {
      return res.status(400).json(error);
    }
};

// ...
Enter fullscreen mode Exit fullscreen mode

Finally, we are going to create a route with the HTTP verb of POST type, which we will use our middleware to perform the validation of the body and, if successful, we will send the data submitted by the user.

app.post("/create",
  validate(dataSchema),
  (req: Request, res: Response): Response => {
    return res.json({ ...req.body });
  }
);
Enter fullscreen mode Exit fullscreen mode

The final code of the example would be as follows:

import express, { Request, Response, NextFunction } from "express";
import { z, AnyZodObject } from "zod";

const app = express();

app.use(express.json());

const dataSchema = z.object({
  body: z.object({
    fullName: z.string({
      required_error: "Full name is required",
    }),
    email: z
      .string({
        required_error: "Email is required",
      })
      .email("Not a valid email"),
  }),
});

const validate =
  (schema: AnyZodObject) =>
  async (req: Request, res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      return next();
    } catch (error) {
      return res.status(400).json(error);
    }
  };

app.get("/", (req: Request, res: Response): Response => {
  return res.json({ message: "Validation with Zod 👊" });
});

app.post("/create",
  validate(dataSchema),
  (req: Request, res: Response): Response => {
    return res.json({ ...req.body });
  }
);

const start = (): void => {
  try {
    app.listen(3333, () => {
      console.log("Server started on port 3333");
    });
  } catch (error) {
    console.error(error);
    process.exit(1);
  }
};
start();
Enter fullscreen mode Exit fullscreen mode

Conclusion

As always, I hope you found it interesting. If you noticed any errors in this article, please mention them in the comments. 🧑🏻‍💻

Hope you have a great day! 🤗

Top comments (15)

Collapse
 
brianmcbride profile image
Brian McBride

The problem is that the types are not defined on the request. That's a general problem with middleware anyway. I have never liked modifying the request object. While it is one of the neat things about Javascript, it can cause confusing bugs when some middleware has an issue.

I might do something like:

import type { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError, z } from 'zod';
import { badRequest } from '@hapi/boom';

export async function zParse<T extends AnyZodObject>(
  schema: T,
  req: Request
): Promise<z.infer<T>> {
  try {
    return schema.parseAsync(req);
  } catch (error) {
    if (error instanceof ZodError) {
      throw badRequest(error.message);
    }
    return badRequest(JSON.stringify(error));
  }
}
Enter fullscreen mode Exit fullscreen mode

Then use that function at the top of my express object.

const { params, query, body } = await zParse(mySchema, req);
Enter fullscreen mode Exit fullscreen mode

I'm a big fan of clearly functional/declarative code.
While it is not quite as DRY as putting everything into middleware, I don't mind if my routes have a stack like: (I'm using an async wrapper for express)

app.get('/', (req, res, next) => {
  const token = await validateToken(req);
  const { params, query: {page, pageSize}, body } = await zParse(mySchema, req);
  await myHandler({token, page, pageSize})
})
Enter fullscreen mode Exit fullscreen mode

Sure, every route has very similar code with mySchema and myHandler the only major differences. The main point here is that anyone can see what this route needs. In this case, a token, page number and the pagination size. With zod I can define defaults, so those values can be required in the handler. And that lets me keep default settings a bit more clean as well.

Collapse
 
spock123 profile image
Lars Rye Jeppesen

I love this, I personally always do my route parsing in the route, as opposed to the middleware, for the same reason: readability of the code. Middleware can quickly become something that magically changes the request object, making it (sometimes) harder to understand what's really going on when debugging or bug fixing.

I don't mind have a "parseRequest" function in my endpoint which parses and returns the props that the endpoint needs. Cheers

Collapse
 
venomfate619 profile image
Syed Aman Ali

`import type { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError, z } from 'zod';
import { badRequest } from '@hapi/boom';

export async function zParse(
schema: T,
req: Request
): Promise> {
try {
return await schema.parseAsync(req);
} catch (error) {
if (error instanceof ZodError) {
throw badRequest(error.message);
}
return badRequest(JSON.stringify(error));
}
}`

return await schema.parseAsync(req);

the await is necessary otherviwe the error won't come in the catch block of the zParse instead it will come in the catch block of the function which is calling it.

Collapse
 
franciscomendes10866 profile image
Francisco Mendes

It's my first time seeing such an approach. Being completely honest, I find it quite interesting. Obviously it's not something that's very generalized, but it's something I'll point out and try out in the future. Thank you very much for your comment, it has an interesting perspective✌️

Collapse
 
brianmcbride profile image
Brian McBride

Obviously it's not something that's very generalized

I love how you said that. This is the trap that we fall in as developers.
While I always say use a well supported lib before going to write your own, the details of our best practices can be improved.

Why do we modify the Request object in Express? How do we expect the developer downstream to know what we added or changed? If we look at more opinionated frameworks, we start to see the use of a context object that is clearly typed and defined. The same javascript developer who extolls the virtue of immutability in their React state engine is the same person who will arbitrarily modify their backend Request source of truth.

I've personally wasted hours going "why doesn't this work" only to find out some middleware we put in was changing the request or response objects.

There is a functional pattern that you could use and it would be clearly declarative and let you modify the data as it passes from function to function and that is using RxJS. If we truly treat the HTTP request as a stream (which it is), RxJS unlocks a LOT. Most of the time, it is too much unless you use observables often and feel comfortable with them.

Outside of programming by tradition, the other advantage of what I've done above is that you get the coercing from zod. In your validation pattern, you aren't going to get defaults, have unwanted keys stripped from your objects, or have transformations applied.

const mySchema = z.object({
  query: z.object({
    page: z.number().optional().default(0),
    pageSize: z.number().optional().default(20),
  }),
});
const { params, query: {page, pageSize}, body } = await zParse(mySchema, req);
Enter fullscreen mode Exit fullscreen mode

Based on my schema, I know 100% that the query page is defined and it is a number with a default of 0 and pageSize is a number, is defined, and has a default of 20. Params will be undefined and body will be undefined.

Collapse
 
felipeex profile image
Felipe Lima • Edited

Or just

const createSchema = z.object({
  body: z.object({
    name: z.string({ required_error: "name is required" }),
    version: z.string({ required_error: "name is required" }),
  }),
});

type createType = z.infer<typeof createSchema>;

const { body }: createType = req.body;
Enter fullscreen mode Exit fullscreen mode
Collapse
 
shahriyardx profile image
Md Shahriyar Alam

How do we add a error handler using this approach?

Collapse
 
bassamanator profile image
Bassam

Error handling is already there. If the zod parse errors out, a status 400 response is sent.

Collapse
 
sagspot profile image
Oliver Sagala

How can you make this function return a conditional partial type as well, for say updating an item?

Collapse
 
bassamanator profile image
Bassam

You can adjust req.body, for example, as needed. All subsequent middlewares/functions will see this change.

Collapse
 
jamesbender profile image
James Bender

Hi,

I tried following this tutorial, but I'm getting this error when I run it:

Error: Must use import to load ES Module

I've run into this on other projects before, and went through the "usual suspects" of changes to try to get it to work, but I was not able to. Do you know what the issue might be?

I'm using Node 16.15.0

Thanks

Collapse
 
bassamanator profile image
Bassam

Are you doing import express from 'express'; or const express=require('express');, for example?

Collapse
 
iamsouravganguli profile image
Sourav Ganguli

Thank you so much

Collapse
 
yksolanki9 profile image
Yash Solanki

This is a very good way to handle req validation in the middleware. Thank you for this article @franciscomendes10866

Collapse
 
mrlectus profile image
LectusMan

I don't see the advantage over express-validator