What request validation does and how Zod, TypeBox and Joi differ
Request validation at the HTTP boundary checks that a body, query string or header set matches an expected shape before any handler logic runs. Everything arriving over the wire is untrusted: a client can send strings where you expect numbers, omit required fields, or add fields you never asked for. That last case drives mass-assignment bugs. If a body like { "email": "...", "role": "admin" } flows straight into an ORM update, the client has written a column it should never touch. Validation also links runtime data to your static types. TypeScript's types disappear at compile time, so without a runtime check a req.body typed as CreateUser is only an assumption.
All three libraries reject bad input, but they produce different things:
| Library | What a schema is | What a check returns | Type coercion | Unknown keys (default) |
|---|---|---|---|---|
| Zod | A TypeScript object with parse methods | A new, typed value (or a thrown or returned error) | Opt-in, plus arbitrary transforms | Stripped |
| Joi | A JavaScript object with a validate method |
{ value, error } |
On by default, controlled by options | Rejected |
| TypeBox | A plain JSON Schema object | Depends on the validator (Ajv or TypeBox's compiler) | Up to the validator's settings | Allowed unless additionalProperties: false |
These differences matter more than syntax. If your contract is defined in TypeScript, Zod fits it. If your contract needs to exist as JSON Schema for OpenAPI or other services, TypeBox fits. If you have an existing JavaScript codebase that already uses Joi's conventions, keeping Joi is often the cheaper option. Choose the library that matches where your source of truth already lives.
How each library works: inference, schema portability and the request pipeline
The deciding difference is where the TypeScript type comes from. In Zod, z.infer<typeof Schema> derives the type from the schema object. In TypeBox, Static<typeof Schema> does the same. Because the schema is the single source, you can't add a required field to the runtime check and forget it in the handler's type. The compiler flags every caller. Joi has no built-in inference. You either write an interface alongside each schema or generate one with community tooling. Either way the type and the check are two artifacts that can drift, and nothing in the build tells you when they do.
Portability splits the libraries differently. A TypeBox schema is a JSON Schema object at runtime. You can pass it unchanged to an OpenAPI document, to Fastify's route schema option for both validation and response serialization, or to a Go or Python service that reads JSON Schema. Zod and Joi schemas are library-specific objects, so producing JSON Schema means converting them. Structural rules convert cleanly. Transforms, custom refinements and conditional logic often have no JSON Schema equivalent and are dropped or approximated. As a result, the published contract can be looser than what the server enforces.
All three libraries fit the same pipeline. They differ in what feeds the type system and the docs:
raw request
│
├── body ──► BodySchema ─┐
├── query ──► QuerySchema ─┼──► validator ──► typed value ──► handler ──► business logic
└── params ──► ParamsSchema ─┘ │
└──► formatted 400 error
+-------------+
| raw request |
+------+------+
|
+-- body ----> [ BodySchema ] --+
+-- query ---> [ QuerySchema ] --+---> +-----------+
+-- params --> [ ParamsSchema ] --+ | validator |
+-----+-----+
|
+-------------------------+------------+
| ok | fail
v v
+----------------+ +---------------------+
| typed value | | formatted 400 error |
+-------+--------+ +---------------------+
v
+----------------+
| handler |
+-------+--------+
v
+----------------+
| business logic |
+----------------+
Handler type: z.infer / Static<> come from the schemas; Joi needs its own type
Body, query and params each pass through their own schema into one validator that returns either a typed value for the handler or a formatted 400 error. Zod and TypeBox derive the handler's type from the same schemas; Joi needs a separate type.
Implementing the same endpoint in Zod, TypeBox and Joi
The example endpoint is PATCH /accounts/:accountId/members/:memberId?notify=true. Its body accepts displayName and timezone, and nothing else. A client that sends role: "owner" must never get that field through to the update query. The endpoint has three schemas, one per request part, because each part arrives in a different shape: params and query are always strings, while the body is parsed JSON.
Coercion
Make coercion explicit in every library. Zod's z.coerce.number() declares the conversion inside the schema. TypeBox schemas are plain JSON Schema, so conversion happens in the validator: either Value.Convert or Ajv's coerceTypes. Joi converts by default through its convert option. Set that option deliberately on query and params and turn it off for the body, where a string "42" is a client bug and shouldn't be silently accepted.
Unknown keys
Each library handles unknown keys differently by default:
- Zod strips unknown keys. Use
.strict()to reject them. - TypeBox follows JSON Schema and allows additional properties. Set
additionalProperties: falseto reject them, or useValue.Cleanor Ajv'sremoveAdditionalto strip them. - Joi rejects unknown keys. Use
stripUnknownto drop them.
For write bodies, reject. A 400 response tells the client it sent something the server ignored. Silent stripping hides the attempt.
Where validation runs
Run validation in middleware or in route-level schema config, and attach the parsed result to the request. The handler then reads only that value, never req.body. Business logic can trust its input types without checking them again, and there is one place to audit what the endpoint accepts.
import express, { Request, Response, NextFunction } from "express";
import { z } from "zod";
declare function updateMember(accountId: string, memberId: number, patch: { displayName?: string; timezone?: string }): Promise<void>;
// One schema per request part, because each part arrives in a different shape.
const Params = z.object({
accountId: z.string().min(1),
memberId: z.coerce.number().int().positive(), // path segments are always strings, so convert explicitly
});
const Query = z.object({
// Don't use z.coerce.boolean() here: Boolean("false") is true. Map the two literal strings instead.
notify: z.enum(["true", "false"]).default("false").transform((v) => v === "true"),
});
const Body = z.object({
displayName: z.string().min(1).max(100).optional(),
timezone: z.string().min(1).optional(),
}).strict(); // No coercion on JSON. Reject unknown keys like role: "owner" instead of stripping them.
const schemas = { params: Params, query: Query, body: Body };
type Input = { [K in keyof typeof schemas]: z.infer<(typeof schemas)[K]> };
function validate(req: Request, res: Response, next: NextFunction) {
const out: Record<string, unknown> = {};
for (const part of ["params", "query", "body"] as const) {
const result = schemas[part].safeParse(req[part] ?? {});
if (!result.success) {
res.status(400).json({ part, issues: result.error.issues });
return;
}
out[part] = result.data;
}
res.locals.input = out as Input; // The handler reads this value, never req.body
next();
}
const app = express();
app.use(express.json());
app.patch("/accounts/:accountId/members/:memberId", validate, async (_req, res) => {
const { params, query, body } = res.locals.input as Input;
await updateMember(params.accountId, params.memberId, body); // already typed and checked; no re-validation
res.status(query.notify ? 202 : 204).end();
});
A Zod version of the PATCH endpoint. It uses separate schemas for params, query and body, explicitly coerces the string path and query values, makes the body schema strict so it rejects mass-assignment fields, and runs validation in Express middleware that passes the typed result to the handler.
Turning validation errors into a consistent API response
Each library reports failures in a different shape, and none of those shapes should reach clients directly. Define one response format you own: a list of entries with field (a dot-joined path such as items.0.quantity), code (a small, stable vocabulary such as required, invalid_type, out_of_range, invalid_format) and a human-readable message.
Mapping each library
- Zod returns all issues by default. Each issue has
pathas an array, acodesuch asinvalid_typeortoo_small, and amessage. Join the path and translate the code into your vocabulary. - TypeBox reports errors through
Value.Errors, which yields entries with a JSON Pointerpath(/items/0/quantity), a numerictypeand amessage. Convert the pointer to your field syntax. If you validate TypeBox schemas with Ajv instead, Ajv stops at the first error unless you enableallErrors. - Joi stops at the first failure because
abortEarlydefaults to true. Set it to false so clients get every problem in one response. Each entry indetailshas an arraypath, a dottedtypesuch asstring.email, and acontextobject.
What not to forward
TypeBox errors include the failing schema and value. Joi's context contains the input value, and some Joi messages echo both the input and the regex it failed against. Forwarding these exposes your validation rules and can reflect attacker-controlled strings into logs or HTML. Build messages from your own code table, and drop unknown keys and raw values.
This formatter is also the anchor for any later migration. Clients and contract tests depend only on your format, so you can swap libraries behind it without changing the API.
import type { ZodError } from 'zod';
import type { ValidationError } from 'joi';
import type { TSchema } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
type Code = 'required' | 'invalid_type' | 'out_of_range' | 'invalid_format' | 'invalid';
export type ApiError = { field: string; code: Code; message: string };
// Messages come from our table only, never from library output or raw input.
const MESSAGES: Record<Code, string> = {
required: 'This field is required.',
invalid_type: 'This field has the wrong type.',
out_of_range: 'This value is outside the allowed range.',
invalid_format: 'This value is not in the expected format.',
invalid: 'This value is invalid.',
};
const entry = (path: ReadonlyArray<PropertyKey>, code: Code): ApiError =>
({ field: path.map(String).join('.'), code, message: MESSAGES[code] });
// Zod: collects all issues by default. Unknown codes fall back to 'invalid'.
const ZOD: Record<string, Code> = { invalid_type: 'invalid_type', too_small: 'out_of_range',
too_big: 'out_of_range', invalid_string: 'invalid_format', invalid_format: 'invalid_format' };
export const fromZod = (err: ZodError) => err.issues.map((i) => entry(i.path, ZOD[i.code] ?? 'invalid'));
// Joi: call schema.validate(input, { abortEarly: false }). Ignore `context`, it holds the input.
const JOI: Record<string, Code> = { 'any.required': 'required', 'string.email': 'invalid_format',
'string.pattern.base': 'invalid_format', 'number.min': 'out_of_range', 'number.max': 'out_of_range',
'string.base': 'invalid_type', 'number.base': 'invalid_type' };
export const fromJoi = (err: ValidationError) => err.details.map((d) => entry(d.path, JOI[d.type] ?? 'invalid'));
// TypeBox: convert the JSON Pointer to dot syntax; drop `schema` and `value` from the output.
const fromPointer = (p: string) => p.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
export const fromTypeBox = (schema: TSchema, input: unknown): ApiError[] =>
[...Value.Errors(schema, input)].map((e) => entry(fromPointer(e.path), e.value === undefined ? 'required' : 'invalid'));
One small formatter per library maps Zod issues, Joi details and TypeBox errors into a shared { field, code, message } format, using messages from your own table so raw input values and schema internals never reach clients.
Who should use which library, and when to avoid each
That separation also makes the choice below less permanent. The right library depends mostly on where your source of truth lives.
| Context | Good fit | Avoid when |
|---|---|---|
| JSON Schema is the contract: OpenAPI-heavy public APIs, Fastify, non-TypeScript consumers | TypeBox | Transforms and cross-field rules dominate |
| TypeScript-first services that need parsing, coercion, transforms and refinements | Zod | Accurate OpenAPI output is mandatory |
| JavaScript services that want mature, expressive runtime validation | Joi | TypeScript codebases without type-generation discipline |
TypeBox produces plain JSON Schema, so the same object drives Fastify's validator, your OpenAPI document and clients in other languages. That stops being an advantage when most of your logic is "if type is refund, orderId is required and amount must not exceed the original charge." JSON Schema handles some conditionals, but these rules get awkward fast, and you end up writing imperative checks after validation anyway.
Zod is pleasant to write, and z.infer keeps handler types honest. The risk is documentation drift. Converting Zod schemas to OpenAPI relies on third-party tooling, and refinements and transforms have no JSON Schema equivalent. The published spec can then accept payloads your server rejects.
Joi has a stable, rich rule set. However, it infers no static types, so TypeScript interfaces written by hand will drift from the schemas unless something enforces that they match.
Hand-written JSON Schema compiled with Ajv is also a reasonable option when a separate team owns the schemas.
Whichever library you pick, validation only checks shape. It does not check authorization or domain invariants such as ownership, state transitions and balances. Those belong in your service layer.
Performance in context
Before any compiled validator can help, the validation call has to be your bottleneck. Zod and Joi validate by walking a schema object at runtime. On every request they dispatch through generic checks for each field. Compiled JSON Schema validators, including TypeBox's compiler, take a different route. They read the schema once and generate a function specialized to that shape. Per-request work then becomes a sequence of direct property checks. On a high-throughput endpoint with large or deeply nested bodies, that difference can be measurable.
In most services, though, a request spends far more time on network I/O, database queries and JSON parsing and serialization than on validation. Making the validator faster rarely changes end-to-end latency unless profiling shows it on the hot path.
Compilation has costs of its own:
- Compile step: Generating the function takes time, so compile at startup, not lazily on the first request.
- Caching: Keep a reference to each compiled validator and reuse it.
- Dynamic schemas: If you build schemas per request, for example from tenant configuration, and compile each one, you pay the compile cost every time and lose the benefit. Generated functions that are never released also cause memory growth. Cache them by a stable key, or fall back to an interpreting validator for those cases.
There is also a build-time cost. Large inferred types from Zod or TypeBox can slow TypeScript type-checking and editor responsiveness. This shows up most in big schemas with many unions or heavy composition.
Benchmark with your own payloads, at realistic sizes, inside your actual request pipeline, before choosing a library on speed.
Impact on cost, engineering time and migration
The largest saving comes from consolidation rather than validation speed. When a single schema produces the runtime check, the TypeScript type and the OpenAPI document, you remove two hand-maintained copies that drift apart. Drift bugs are expensive because they compile cleanly, pass review and appear only when a client sends a field the docs promised was optional. Standardizing on one library also shortens onboarding, since new engineers learn one schema vocabulary and one error shape.
Standardization has costs too. A team building a schema-first public API may need TypeBox's JSON Schema output, while a team writing internal TypeScript services may prefer Zod's ergonomics. Forcing one library on both can trade drift bugs for workarounds. Standardize the error contract, not necessarily the library.
Migrating incrementally
Move one route at a time. The shared error formatter from earlier stays in place, so clients see no change in response shape while the validator behind it changes.
For high-traffic routes, run both validators in shadow mode for a period. Let the old validator decide the outcome, run the new one alongside it, and log any request where they disagree on pass/fail or on the parsed output. Cut over only after the disagreement log is empty or each entry is explained.
Most disagreements come from defaults that differ between libraries:
- Unknown keys. Joi rejects them by default, Zod strips them, and Ajv-validated TypeBox schemas allow them unless
additionalPropertiesis set to false. - Type conversion. Joi converts
"42"to42by default. Zod and Ajv do not unless you configure coercion. - Error collection. Joi stops at the first error unless
abortEarlyis disabled, Ajv does the same unlessallErrorsis enabled, and Zod reports all issues. Clients that parse error arrays will notice the difference.
import Joi from "joi";
import { z } from "zod";
import { isDeepStrictEqual } from "node:util";
// Old validator. Joi's defaults reject unknown keys and convert "42" to 42.
const joiSchema = Joi.object({
name: Joi.string().required(),
quantity: Joi.number().integer().min(1).required(),
});
// New validator. Opt in explicitly so Zod's defaults match Joi's.
const zodSchema = z
.object({
name: z.string(),
quantity: z.coerce.number().int().min(1), // match Joi's string-to-number conversion
})
.strict(); // reject unknown keys instead of silently stripping them
export function validateCreateOrder(body: unknown, route: string) {
const legacy = Joi.isSchema(joiSchema) && joiSchema.validate(body, { abortEarly: false });
const candidate = zodSchema.safeParse(body);
const legacyOk = !legacy.error;
// Disagreement means a different pass/fail result or different parsed output.
const outputDiffers =
legacyOk && candidate.success && !isDeepStrictEqual(legacy.value, candidate.data);
if (legacyOk !== candidate.success || outputDiffers) {
// Log key names, not values, to keep request data out of logs.
const keys = body && typeof body === "object" ? Object.keys(body) : [];
console.warn(JSON.stringify({ event: "validator_disagreement", route, legacyOk, candidateOk: candidate.success, keys }));
}
// Joi still decides the outcome until the disagreement log is empty or explained.
// Pass either result through the shared error formatter so clients see one shape.
return legacyOk
? { ok: true as const, value: legacy.value }
: { ok: false as const, error: legacy.error };
}
A shadow-mode wrapper for one route: Joi still decides each request, a Zod schema configured to match Joi's defaults on unknown keys and type conversion runs alongside it, and any difference in pass/fail or parsed output is logged before cutover.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to node.js request validation, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.