⭐ 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
Next, initialize an Express.js project and add the necessary dependencies:
npm init -y
npm install express zod
Now let's update some configs in our package.json file.
{
...
"type": "module",
"main": "index.mjs",
...
}
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}`)
})
For the API to be initialized on port 3000 just run the following command:
node index.mjs
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'),
}),
})
// ...
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)
}
}
// ...
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 })
})
// ...
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}`)
})
Testing
1️⃣ Case 1: Blank object
{}
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"
}
2️⃣ Case 2: fullName and email are empty
{
"fullName": "",
"email": ""
}
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"
}
3️⃣ Case 3: valid fullName, invalid email format
{
"fullName": "Nhan Nguyen",
"email": "sample@gmail"
}
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"
}
4️⃣ Case 4: both fullName and email are valid
{
"fullName": "Nhan Nguyen",
"email": "sample@gmail.com"
}
We get a response with the status code 200 OK:
{
"fullName": "Nhan Nguyen",
"email": "sample@gmail.com"
}
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:
- Medium: https://medium.com/@nhannguyendevjs/
- Dev: https://dev.to/nhannguyendevjs/
- Hashnode: https://nhannguyen.hashnode.dev/
- Linkedin: https://www.linkedin.com/in/nhannguyendevjs/
- X (formerly Twitter): https://twitter.com/nhannguyendevjs/
- Buy Me a Coffee: https://www.buymeacoffee.com/nhannguyen/
Top comments (2)
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?
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).