API Schema Validation with Zod in TypeScript
Zod is a TypeScript-first schema validation library with zero dependencies. It makes it easy to define your schema once and use it for both runtime validation and TypeScript type checking.
In this article, we'll take a look at how to use Zod to build a simple API with TypeScript and validate the requests.
Getting Started
You can find the complete code for this article at https://github.com/endpts-samples/zod-validation. Feel free to fork and clone the repository or follow along with the steps below.
First things first, let's go ahead and create a new project. We'll use the create-endpts package to bootstrap an endpts project. This will allow us to build and test our API locally and later deploy it to a serverless environment when we're ready.
npm create endpts@latest zod-api
Next, we'll install the Zod package:
cd zod-api
npm install zod --save
Defining the Schema
Let's create our first API endpoint and define the schema for the request body. We will create a signup endpoint that accepts a JSON request body with the following properties:
name
- a string between 1 and 50 charactersemail
- a valid email addresspassword
- a string with at least 8 charactersrole
- one ofadmin
,developer
, oruser
We'll use Zod to define the schema for the request body and validate the request before creating a new user in the database:
import { z, ZodError } from 'zod'
const userSchema = z
.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(50, 'Name cannot be longer than 50 characters'),
email: z.string({ required_error: 'Email is required' }).email(),
password: z
.string({ required_error: 'Password is required' })
.min(8, 'Password must be at least 8 characters'),
role: z.enum(['admin', 'developer', 'user'], {
errorMap: () => ({
message: "Role must be one of 'admin', 'developer', or 'user'",
}),
}),
})
.strict()
Let's break down what's happening here. First, we define the userSchema
using the z.object
method. This method accepts an object with the schema for each property. By default, all properties are required unless they are marked as optional using the z.optional
method.
Zod offers a number of methods that can be chained together to build more specific validations. You can see this with the z.string().min().max()
chain to specify that the name
property must be a string with a minimum length of 1 and a max length of 50. Similarly, we're using z.string().email()
to further specify that the string in the email
property must be a valid email address.
Check out the complete list of primitives supported by Zod
Another thing to note is the error messages we're specifying. By default, Zod generates error messages when validation fails. However, we can provide more descriptive and human-readable error messages by passing an additional argument to the validation methods.
In our example, if the user specifies a name
that is greater than 50 characters, the error message will be Name cannot be longer than 50 characters
:
name: z.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(50, 'Name cannot be longer than 50 characters')
Additionally, when calling z.string()
above, we pass an error map that is used to specify custom error messages depending on the type of error encountered. In the case of the name
property, if the user does not specify the name
property at all in the request body, they will see the error message Name is required
.
Finally, the .strict()
method at the end of the chain tells Zod to throw an error if the request body contains any properties that are not defined in the schema. This is useful to ensure that the request body only contains the properties that we expect.
Inferring the TypeScript Type
One of the most powerful features of Zod is its ability to infer the TypeScript type from the schema. This means that we can use the userSchema
to validate the request body at runtime and also use it to for static type checking and type hints while developing our API.
For our example, assume we have a createUser
function that accepts a User
object and creates a new user in the database. With Zod, we don't have to separately define our TypeScript interfaces/types, we can simply use the userSchema
to infer the type, like so:
type User = z.infer<typeof userSchema>
Now, let's put this together with the createUser
function:
// create a User type from the userSchema
type User = z.infer<typeof userSchema>
// create a userResponseSchema that extends the userSchema
// with an id property and omits the password property
const userResponseSchema = userSchema
.extend({
id: z.string(),
})
.omit({ password: true })
type UserResponse = z.infer<typeof userResponseSchema>
// the database function to create a new user. It accepts a User object
// and returns a UserResponse object
function createUser(user: User): UserResponse {
const id = randomUUID()
// hash the user's password and create the new user in the database
return { id, name: user.name, email: user.email, role: user.role }
}
You'll notice we defined a second schema called userResponseSchema
that extends the userSchema
with an id
property that's generated by our database function.
We also use the omit
method to remove the password
property from the returned object of the createUser
function since we don't want to return the plain-text password to other functions or the end-user once it has been stored in the database.
Validating the Request Body
Now that we have our schemas defined, let's put this all together in our POST /signup
endpoint where we'll accept a JSON request body, validate it using the userSchema
, and create a new user in the database. If the request body is invalid, we'll return a 400 Bad Request
response with the error message.
Create a new route under the routes/
directory called post_signup.ts
and add the following code:
// routes/post_signup.ts
import { z, ZodError } from 'zod'
import { randomUUID } from 'crypto'
import type { Route } from '@endpts/types'
const userSchema = z
.object({
name: z
.string({ required_error: 'Name is required' })
.min(1, 'Name cannot be empty')
.max(50, 'Name cannot be longer than 50 characters'),
email: z.string({ required_error: 'Email is required' }).email(),
password: z
.string({ required_error: 'Password is required' })
.min(8, 'Password must be at least 8 characters'),
role: z.enum(['admin', 'developer', 'user'], {
errorMap: () => ({
message: "Role must be one of 'admin', 'developer', or 'user'",
}),
}),
})
.strict()
type User = z.infer<typeof userSchema>
const userResponseSchema = userSchema
.extend({
id: z.string(),
})
.omit({ password: true })
type UserResponse = z.infer<typeof userResponseSchema>
function createUser(user: User): UserResponse {
const id = randomUUID()
// hash the user's password and create the new user in the database
return { id, name: user.name, email: user.email, role: user.role }
}
export default {
method: 'POST',
path: '/signup',
async handler(req) {
// read the request body as JSON
const body = await req.json()
// validate the request body against the schema
let user
try {
user = userSchema.parse(body)
} catch (err: any) {
// return a 400 response if the request body is invalid
if (err instanceof ZodError) {
const errors = err.issues.map((e) => ({
property: e.path[0],
message: e.message,
}))
return Response.json(errors, { status: 400 })
}
return new Response('An unknown error occured', { status: 500 })
}
return Response.json(createUser(user))
},
} satisfies Route
With the schemas correctly defined, we can now call userSchema.parse(body)
to validate the request body against the schema. If the request body is invalid, Zod will throw a ZodError
. Otherwise, it will return the validated User
object that is fully typed:

Testing our API
Now we can fire up the local dev server and test our API:
npm run dev

First let's call our API with a missing name
property. We should expect to see a 400 Bad Request
response with the error message Name is required
:
curl -XPOST \
-d '{ "email": "emma@example.com", "password": "my-secret-password", "role": "admin"}' \
http://localhost:3000/signup

Next, let's try to call the /signup
endpoint with an invalid email
property. We should expect to see a 400 Bad Request
response with the error message Invalid email
:
curl -XPOST \
-d '{ "name": "Emma", "email": "not-an-email", "password": "my-secret-password", "role": "admin"}' \
http://localhost:3000/signup

Finally, let's call the /signup
endpoint with a valid user. We should expect to see a user object returned with an id
property and the password
property omitted:
curl -s -XPOST \
-d '{ "name": "Emma", "email": "emma@example.com", "password": "my-secret-password", "role": "admin"}' \
http://localhost:3000/signup | jq

Conclusion
We've only scratched the surface of what's possible with Zod. I'd encourage you to explore the Zod documentation to learn more about the different validation methods and how to use them.
When you're ready, you can deploy your API to endpts Serverless Functions. endpts is a platform that aims to be the easiest way to build and deploy your Node.js APIs. It's free to get started and you can deploy your first API in under 5 minutes.
