Validation with Joi & Zod
Never trust input that crosses a boundary — request bodies, query strings, environment variables, and third-party API responses can all arrive malformed. Schema validation libraries let you declare the exact shape of acceptable data once and reject everything else with clear errors. Joi is the battle-tested, runtime-only validator born in the Hapi ecosystem, while Zod is the modern TypeScript-first library whose schemas double as static types. This page compares the two and shows how to validate request bodies in an Express-style API.
Why schema validation
Hand-rolled if (!body.email) checks scatter validation logic, miss edge cases, and never produce consistent error messages. A schema centralizes the rules — types, required fields, formats, ranges, and custom constraints — and returns a structured report of every failure. The two leading choices solve the same problem with different philosophies.
| Concern | Joi | Zod |
|---|---|---|
| Primary audience | Any Node.js project | TypeScript-first |
| Static type inference | No | Yes (z.infer) |
| Bundle size | Larger | Small, tree-shakeable |
| API style | Chained, fluent | Chained, composable |
| Default behavior | Strips unknown by config | Strips unknown by default |
| Async validation | Built-in (validateAsync) | parseAsync / refinements |
Reach for Zod when you write TypeScript and want one source of truth for both runtime checks and compile-time types. Reach for Joi for plain JavaScript projects or when you need its rich, mature rule set.
Defining and validating with Joi
Install the package, then build a schema with the fluent Joi builder. Calling schema.validate(data) returns an object containing error (a ValidationError or undefined) and the coerced value.
npm install joi
import Joi from "joi";
const userSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18).max(120),
role: Joi.string().valid("admin", "user").default("user"),
}).options({ abortEarly: false, stripUnknown: true });
const { error, value } = userSchema.validate({
name: "A",
email: "not-an-email",
age: 15,
});
if (error) {
console.log(error.details.map((d) => d.message));
} else {
console.log("Valid:", value);
}
The abortEarly: false option collects all failures instead of stopping at the first, and stripUnknown discards properties not declared in the schema.
Output:
[
'"name" length must be at least 2 characters long',
'"email" must be a valid email',
'"age" must be greater than or equal to 18'
]
Defining and validating with Zod
Zod schemas are composed from z primitives. Use .parse() to validate and throw on failure, or .safeParse() to get a discriminated result object without exceptions — the latter is preferable in request handlers.
npm install zod
import { z } from "zod";
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(18).max(120).optional(),
role: z.enum(["admin", "user"]).default("user"),
});
const result = userSchema.safeParse({
name: "A",
email: "not-an-email",
age: 15,
});
if (!result.success) {
console.log(result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`));
} else {
console.log("Valid:", result.data);
}
Output:
[
'name: String must contain at least 2 character(s)',
'email: Invalid email',
'age: Number must be greater than or equal to 18'
]
Unlike Joi, Zod strips unknown keys by default and throws (with .parse()) rather than returning an error object, so wrap it in safeParse for control flow.
TypeScript type inference with Zod
Zod’s headline feature is z.infer, which derives a static TypeScript type directly from a schema. This eliminates the classic problem of an interface and a validator drifting out of sync — change the schema and the type updates automatically.
import { z } from "zod";
const userSchema = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
});
type User = z.infer<typeof userSchema>;
// type User = { name: string; email: string; role: "admin" | "user" }
function greet(user: User) {
return `Hello ${user.name} (${user.role})`;
}
Joi can produce types via the third-party joi-extract-type package, but it is not first-class — this is where Zod has a decisive edge.
Validating request bodies
Both libraries slot naturally into Express middleware. A reusable factory keeps controllers clean by validating before the handler runs and returning a 400 with structured errors on failure.
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
errors: result.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
});
}
req.body = result.data; // sanitized, typed payload
next();
};
app.post("/signup", validate(signupSchema), (req, res) => {
res.status(201).json({ email: req.body.email });
});
app.listen(3000);
The equivalent Joi middleware swaps safeParse for schema.validate(req.body, { abortEarly: false }) and reads error.details. With Joi you typically pass validateAsync when rules involve async checks like database lookups.
Always assign the validated, coerced value back onto
req.body. Both libraries return a cleaned copy with defaults applied and unknown keys stripped — using the raw input downstream defeats the purpose of validation.
Best practices
- Validate at the edge — request bodies, query params, and environment variables — before any business logic touches the data.
- Collect every error in one pass (
abortEarly: falsein Joi; Zod does this natively) so clients can fix all problems at once. - Prefer
safeParse/validateover throwing variants inside request handlers to keep control flow explicit and avoid leaking stack traces. - In TypeScript projects, derive types with
z.inferso your runtime schema and compile-time types can never diverge. - Always use the returned sanitized value, not the original input, so defaults and stripped fields take effect.
- Compose and reuse schemas (
.extend(),.merge(),.partial()) instead of duplicating field definitions across endpoints. - Map validation failures to a consistent
400error shape so frontends can render field-level messages reliably.