DEV Community

Nhan Nguyen
Nhan Nguyen

Posted on • Updated on

Zod - TypeScript-first schema declaration and validation library #tips

Image description

Schema Validation with Zod and Express.js

Overview

In the past, I have done articles on what is Zod, and how to use Zod to declare a validator once and compose simpler types into complex data structures.

Today's example

Today, I will write an article where we will create middleware to validate a specific route's schema.

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-expressjs-sample
cd zod-expressjs-sample
Enter fullscreen mode Exit fullscreen mode

Next, initialize an Express.js project and add the necessary dependencies:

npm init -y
npm install express zod
Enter fullscreen mode Exit fullscreen mode

Now let's update some configs in our package.json file.

{
  ...
  "type": "module",
  "main": "index.mjs",
  ...
}
Enter fullscreen mode Exit fullscreen mode

Let's code

And now let's create a simple API:

// Path: index.mjs

import express from 'express'
import cors from 'cors'

const app = express()

const port = 3000

app.use(cors())
app.use(express.json({ limit: '50mb' }))

app.get('/', (req, res) => {
  res.json({ message: 'Hello World!' })
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

Enter fullscreen mode Exit fullscreen mode

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

node index.mjs
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:

// Path: index.mjs

import express from 'express'
import cors from 'cors'
import zod from 'zod'

const app = express()

const port = 3000

app.use(cors())
app.use(express.json({ limit: '50mb' }))

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

// ...
Enter fullscreen mode Exit fullscreen mode

Now we can create our middleware. When the user calls 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.

// Path: index.mjs

// ...

const validate = (schema) => async (req, res, next) => {
  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.

// Path: index.mjs

// ...

app.post('/create', validate(dataSchema), (req, res) => {
  return res.json({ ...req.body })
})

// ...
Enter fullscreen mode Exit fullscreen mode

The final code of the example would be as follows:

// Path: index.mjs

import express from 'express'
import cors from 'cors'
import zod from 'zod'

const app = express()

const port = 3000

app.use(cors())
app.use(express.json({ limit: '50mb' }))

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

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

app.post('/create', validate(dataSchema), (req, res) => {
  return res.json({ ...req.body })
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})
Enter fullscreen mode Exit fullscreen mode

Testing

1️⃣ Case 1: Blank object

{}
Enter fullscreen mode Exit fullscreen mode

We get a response with the status code 400 Bad Request:

{
    "issues": [
        {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
                "body",
                "fullName"
            ],
            "message": "Full name is required"
        },
        {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
                "body",
                "email"
            ],
            "message": "Email is required"
        }
    ],
    "name": "ZodError"
}
Enter fullscreen mode Exit fullscreen mode

2️⃣ Case 2: fullName and email are empty

{
    "fullName": "",
    "email": ""
}
Enter fullscreen mode Exit fullscreen mode

We get a response with the status code 400 Bad Request:

{
    "issues": [
        {
            "validation": "email",
            "code": "invalid_string",
            "message": "Not a valid email",
            "path": [
                "body",
                "email"
            ]
        }
    ],
    "name": "ZodError"
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ Case 3: valid fullName, invalid email format

{
    "fullName": "Nhan Nguyen",
    "email": "sample@gmail"
}
Enter fullscreen mode Exit fullscreen mode

We also get a response with the status code 400 Bad Request:

{
    "issues": [
        {
            "validation": "email",
            "code": "invalid_string",
            "message": "Not a valid email",
            "path": [
                "body",
                "email"
            ]
        }
    ],
    "name": "ZodError"
}
Enter fullscreen mode Exit fullscreen mode

4️⃣ Case 4: both fullName and email are valid

{
    "fullName": "Nhan Nguyen",
    "email": "sample@gmail.com"
}
Enter fullscreen mode Exit fullscreen mode

We get a response with the status code 200 OK:

{
    "fullName": "Nhan Nguyen",
    "email": "sample@gmail.com"
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

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

Hope you have a great day! 🤗


I hope you found it useful. Thanks for reading. 🙏

Let's get connected! You can find me on:

Top comments (2)

Collapse
 
zoranduric profile image
Zoran

If you would have more schemas would it be bad paratice to handle error returns in middleware and do schema validation in each method call?

Collapse
 
nhannguyendevjs profile image
Nhan Nguyen

Hi, Zoran! Thanks for reading.

This article is simple and applies to a specific route's schema. If we have more schemas, we can use them in another place (Controller or Model).