← Back to list

Stop Using If-Else Chains to Validate Your Express Payloads, Use Zod

You don’t know what is coming in your payloads. When working with req.body, can you really be sure it contains all the fields you expect…

Ismail Bin Mujeeb · 2026-06-06 06:06 · 0 claps · 4.4 min read
#expressjs #zod #payload #validation #javascript
Open on Medium ↗
Wiki topics: 🌐 · Web Development 📰 · Journalism & News

Stop Using If-Else Chains to Validate Your Express Payloads, Use Zod

You don’t know what is coming in your payloads. When working with req.body, can you really be sure it contains all the fields you expect? No. You are completely in the dark.

This is a problem almost every Express.js developer runs into, especially early on. You start with a couple of if-else checks, then a few more, then before you know it your route handler is 80 lines of validation garbage before any actual business logic even runs. Messy, hard to read, and painful to maintain.

We Can’t Predict What Users Will Send

We are not astrologers. We cannot predict what a user will send or what a “creative” frontend developer might pass to our API. But we can protect against it.

There are plenty of packages on npm for this like express-validator, yup, joi. Today we are talking about Zod specifically, because it is TypeScript-first, has a clean API, and the error messages it spits out are actually useful.

What We Are Building?

A small Express app with:

  • A /api/auth/register route
  • A reusable Zod middleware that validates req.body against any schema you give it
  • Clean, structured error responses when validation fails

Here is the folder structure we will follow:

project/
├── server.js
├── routes/
│   └── user.route.js
├── controllers/
│   └── user.controller.js
├── middlewares/
│   └── zod.middleware.js
└── schema/
    └── userRegistration.schema.js

Step 1: Install Zod

Pick your package manager:

npm install zod

Step 2:Set Up the Express App

// server.js
import express from "express";
import userRouter from "./routes/user.route.js";

const app = express();
const port = 3000;

app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use("/api/auth", userRouter);
app.listen(port, () => {
  console.log(`Server is running on http://localhost:${port}`);
});

Nothing fancy. Import express, hook up the router, add the JSON middleware so req.body actually works, and listen on port 3000.

Don’t forget express.json() , without it, req.body will be undefined and you will spend 20 minutes debugging for no reason.

Step 3: Write the Zod Middleware

This is the core piece. One middleware, reusable across every route.

// ./middlewares/zod.middleware.js
export default (zodSchema) => (req, res, next) => {
  const result = zodSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: result.error.flatten() });
  }
  req.body = result.data;
  next();
};

If that arrow function returning an arrow function looks weird, here is the same thing written out more explicitly:

export default (zodSchema) => {
  return (req, res, next) => {
    const result = zodSchema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({ error: result.error.flatten() });
    }
    req.body = result.data;
    next();
  };
};

Why does it work this way?

  • We export a function that takes a zodSchema.
  • That function returns the actual Express middleware (the one with req, res, next).
  • This lets us call zodMiddleware(someSchema) directly in the route definition, different schemas for different routes, with single middleware.

What does safeParse do?

Unlike .parse(), safeParse does not throw on failure. It returns either { success: true, data: ... } or { success: false, error: ... }. That is what we want, we handle the failure ourselves and return a clean 400.

Why do we overwrite req.body with result.data?

Because Zod strips out any extra fields that are not in your schema. So if someone sends { email, password, username, isAdmin: true } and isAdmin is not in your schema, it will be removed before it reaches your controller. Free protection against unexpected fields.

Step 4: Define the Zod Schema

Before writing our registration schema, here is a quick look at what Zod can do:

import { z } from "zod";

// A few common validators
z.string()           // must be a string
z.string().email()   // must be a valid email
z.string().url()     // must be a valid URL
z.string().min(8)    // minimum length
z.string().max(100)  // maximum length
z.number().min(1)    // minimum value
z.boolean()          // true or false
z.array(z.string())  // array of strings
z.object({ ... })    // an object with a specific shape

Now our actual registration schema:

// ./schema/userRegistration.schema.js
import { z } from "zod";

export default z.object({
  email: z.string().email(),
  password: z.string().min(8).max(100),
  username: z.string().min(2).max(100),
});

Three fields, each with its own rules. If any of them fail, Zod tells you exactly which field failed and why.

Step 5: The Controller

// ./controllers/user.controller.js
export const registerUser = async (req, res) => {
  try {
    const { email, password, username } = req.body;
    // Your actual registration logic goes here:
    // - Hash the password with bcrypt
    // - Save the user to the database
    // - Generate a JWT token if needed
    res.status(201).json({
      message: "User registered successfully",
      user: { id: "123", email, username },
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
};

By the time this runs, req.body is already validated. No extra checks needed. The middleware handled the dirty work.

Step 6: Wire It All Together in the Router

// ./routes/user.route.js
import { Router } from "express";
import { registerUser } from "../controllers/user.controller.js";
import userRegistrationSchema from "../schema/userRegistration.schema.js";
import zodMiddleware from "../middlewares/zod.middleware.js";

const router = Router();
router.post("/register", zodMiddleware(userRegistrationSchema), registerUser);
export default router;

The key line is:

router.post("/register", zodMiddleware(userRegistrationSchema), registerUser);

Arguments:

  1. The endpoint path
  2. zodMiddleware(userRegistrationSchema) this calls our middleware factory with the schema, which returns the actual middleware function Express will run
  3. registerUseronly runs if validation passes

What Happens When Someone Sends Invalid Data

Payload sent:

{
  "password": "12348",
  "username": "Thisismyusername"
}

Two problems: email is missing, and password is under 8 characters.

Response from the API:

{
  "error": {
    "formErrors": [],
    "fieldErrors": {
      "email": [
        "Invalid input: expected string, received undefined"
      ],
      "password": [
        "Too small: expected string to have >=8 characters"
      ]
    }
  }
}

Clean, structured, and actually useful. Your frontend developer will know exactly what to fix.

Global Error Handling for Unexpected Failures

Right now if something blows up outside of Zod validation, you are relying on the try-catch in each controller. A better approach is a global error handler at the end of your Express app:

// server.js (add this after your routes)
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: "Something went wrong" });
});

Express catches errors thrown inside middleware and controllers and passes them to this handler. Keeps your controllers cleaner.

Wrapping Up

Here is the full picture of what we built:

  1. One middleware (zod.middleware.js) that takes any Zod schema and validates req.body against it
  2. One schema per route, defined separately and easy to update
  3. Controllers that only run when the payload is already valid
  4. Clean 400 errors with field-level details when validation fails

The pattern scales well. Got a new route? Write a schema, plug it into the middleware, done. No copy-pasting if-else blocks, no spaghetti validation logic inside controllers.

If this helped you, feel free to connect on GitHub, Instagram, X, or LinkedIn.

Full code is in the repository.


메타데이터
post_id
e5b38cbdff4e
slug
stop-using-if-else-chains-to-validate-your-express-payloads-use-zod-e5b38cbdff4e
url
https://medium.com/@ismailbinmujeeb/stop-using-if-else-chains-to-validate-your-express-payloads-use-zod-e5b38cbdff4e
canonical_url
https://medium.com/@ismailbinmujeeb/stop-using-if-else-chains-to-validate-your-express-payloads-use-zod-e5b38cbdff4e
author_url
https://medium.com/@ismailbinmujeeb
status
ok
fetched_at
2026-06-14 11:28:49