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

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 characters
  • email - a valid email address
  • password - a string with at least 8 characters
  • role - one of admin, developer, or user

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.

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:

Type hints for the parsed user object

Testing our API

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

npm run dev
Start the endpoints local development server

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
Call the /signup endpoint with a missing name property

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
Call the /signup endpoint with an invalid email

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
Call the /signup endpoint with a valid user

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.

Deploy the API via the endpts Dashboard