Oscar's Dev Blog


2023-02-18

Building bulletproof ExpressJS APIs with Zod


Introduction

As a fullstack developer, building robust API endpoints with good error handling is a very important skill. This post will show how the Zod schema validation/declaration library can be used to make solid API endpoints with good error feedback in a few lines of code.

What is Zod?

Zod is a schema validation npm library used to check types at runtime. Sounds neat but why do we need this? Isn’t that exactly what Typescript is for? Well Typescript types are great, but they only really help during development. While Typescript types do offer some runtime checking, it is not as comprehensive as Zod. Specifically, Typescript types only validate that the expected properties exist, while Zod can validate the values of those properties.

Here’s an example of a Zod schema object:

const schema = z.object({
	user: z.object({
		firstName: z.string().min(2),
		lastName:z.string().optional(),
		age: z.int(),
		email: z.string().email()
	})
})

We can then process some data using this schema with the safeParse() function

const data: unknown = ...

const parsedData = schema.safeParse(data);

if (parsedData.success) {
	const safeData = parsedData.data
	// safeData passes validation and is safe to use! ☑️
}

By parsing the data through our Zod schema object, we can guarantee that safeData object contains a user with a firstName string property of at least 2 characters long, a lastName property (either string or undefined), an integer age and a valid email address. All this is done in a few lines of code. For the next step, we will look at how we can implement this on an Express JS endpoint.

Using Zod with Express

For this demo, we will validate common request headers on an Express route using Zod using middleware. Any handler in the same context of this middleware will also have the same validation. We will start this task by making a schema for our validation.

Schema

We want to make sure every request has a header guest-user-id with a prefix of gid- and a minimum length of 12 characters.

const requestSchema = z.object({
  headers: z.object({
    "guest-user-id": z.string().min(12).startsWith("gid-"),
  })
});

Middleware

Next, we want to create a middleware function which checks the input data against the schema and returns a 400 (bad request) if the input is invalid.

app.use((req, res, next) => {
  const input = requestSchema.safeParse(req);
  if (!input.success) {
    return res.status(400).send(input.error.issues);
  }
  res.locals = input;
  next();
});

This function sends all of the issues with the Zod parsing as a response which outlines the parameters which did were not valid and why. We then set the cleaned data in res.locals. This does not send any data back but it is just a way of setting custom data in our request lifecycle which can be picked up at any point later down the line.

For example, if we send a bad request with the guest-user-id header set with a value of guest-123, we get a full list of all of the issues with our request. This makes debugging issues very easy.

[
    {
        "code": "too_small",
        "minimum": 12,
        "type": "string",
        "inclusive": true,
        "exact": false,
        "message": "String must contain at least 12 character(s)",
        "path": [
            "headers",
            "guest-user-id"
        ]
    },
    {
        "code": "invalid_string",
        "validation": {
            "startsWith": "gid-"
        },
        "message": "Invalid input: must start with \"gid-\"",
        "path": [
            "headers",
            "guest-user-id"
        ]
    }
]

Warning: Do not include sensitive information in the Zod validation schema (like API tokens) while returning input.error.issues, as requestors will be able to see why their request was rejected and thus, exposing the sensitive information. These checks should be handled separately with the appropriate error code (like HTTP 401) and Zod errors should not be sent back.

Endpoint handler

Finally, we can access the safe input on the handler from the res.locals where we set it in the middleware. We can also make this input Typescript safe by defining the input as the inferred type of the Zod schema.

app.get("/", (req, res) => {
  const input = res.locals as z.infer<typeof requestSchema>;
  const guestUserId = input.headers["guest-user-id"];

  return res.send({ message: `Your guest user ID is ${guestUserId}` });
});

All together now!

Here is the full endpoint with all of the above steps put together. As you can see, in not very many lines of code, we have a robust and strongly-typed API.

// schema
const requestSchema = z.object({
  headers: z.object({
    "guest-user-id": z.string().min(12).startsWith("gid-"),
  })
});

// middleware
app.use((req, res, next) => {
  const input = requestSchema.safeParse(req);
  if (!input.success) {
    return res.status(400).send(input.error.issues);
  }
  res.locals = input;
  next();
});

// endpoint handler
app.get("/", (req, res) => {
  const input = res.locals as z.infer<typeof requestSchema>;
  const guestUserId = input.headers["guest-user-id"];

  return res.send({ message: `Your guest user ID is ${guestUserId}` });
});

The old-school alternative

If we wanted to implement the same safety without Zod, our middleware function would look something like this. That’s a lot of code to validate one property. If we have multiple properties, you can imagine how large this validation function would be! This type of validation is prone to errors and can quickly become unwieldy when dealing with multiple properties, whereas Zod provides a more streamlined and less error-prone solution.

router.use((req, res, next) => {
  const guestUserId = req.headers.guestUserId;
  if (typeof guestUserId !== "string") {
    return res.status(400).send({ message: "guestUserId is missing" });
  }
  if (guestUserId.length < 12) {
    return res.status(400).send({ message: "guestUserId is too short" });
  }
  if (!guestUserId.startsWith("gid-")) {
    return res.status(400).send({ message: "guestUserId is invalid" });
  }

  res.locals = { guestUserId };
  next();
});

Summary

In this blog post, we explored how to use Zod, a TypeScript-first schema validation library, to validate data in Express APIs. We looked at how Zod makes it easy to define and validate data schemas on the fly, and how it can help catch errors early in the development process. By integrating Zod with Express, we can ensure that only valid data is accepted by our APIs, and that any invalid data is rejected with informative error messages in few lines of code.

Validating data is an essential part of building secure and reliable APIs, and Zod provides a powerful and convenient way to do so. By adopting best practices like data validation, we can build more robust applications and avoid common security vulnerabilities.

If you're interested in learning more about building secure and reliable software, be sure to check out our other article on hacking BeReal. In it, we explore Bereal’s API endpoints and intercept requests to upload whatever photos we like.