Node.js 55 🟢 Runtime Input Validation and Sanitization with Zod
TypeScript checks types at compile time. It does not exist at runtime. When a request arrives from the network, the body is a JSON object of unknown shape, the query parameters are strings, and the headers are strings. The TypeScript compiler has no opinion about any of them, because the data does not exist when the code is compiled. Runtime validation is the step that checks the actual values against the expected shape and either rejects the request or narrows the type for the rest of the handler. Zod is the library that makes this step declarative, composable, and type-safe. A schema is defined once, and it serves three purposes: it validates at runtime, it infers the TypeScript type, and it parses and transforms the data into the shape the application expects.
Key point: TypeScript types are erased at runtime. A function typed to accept { email: string } will happily receive { email: 42 } from the network and fail later, in a place far from the boundary. Validation belongs at the boundary, and the schema that validates is also the source of the type.
Why runtime validation exists
The type erasure problem. TypeScript compiles to JavaScript, and JavaScript has no types. Every interface, type alias, and generic is stripped during compilation. The runtime sees only objects, arrays, strings, and numbers. A User interface is a compile-time fiction that disappears the moment the code runs.
The trust boundary problem. Data from outside the application cannot be trusted. It comes from HTTP requests, message queues, environment variables, files, and third-party APIs. Every one of these sources can produce data that does not match the expected shape. The application must check, not assume.
The validation scatter problem. Without a schema library, validation becomes a sequence of if statements: check that the field exists, check that it is the right type, check that it is within range, check that the format matches. The checks are scattered across the handler, duplicated in other handlers, and rarely exhaustive.
The type inference problem. When validation is written by hand, the TypeScript type and the runtime check are separate. They can drift. A field added to the type is not added to the check, and the check accepts data the type does not describe. A schema library derives the type from the schema, so the two cannot diverge.
The transformation problem. Input often needs normalization: trimming whitespace, lowercasing email addresses, parsing date strings, coercing query parameters from strings to numbers. Hand-written validation does not transform; it only checks. A schema library parses and transforms, producing the final value in one step.
The error message problem. Hand-written validation produces error messages inconsistently. Some handlers return "invalid input", others return the field name, others throw. A schema library produces structured errors that can be formatted uniformly for the API response.
a. Defining schemas
A Zod schema is a value that describes the expected shape. It is defined once and reused for validation, type inference, and transformation.
Primitive schemas
The primitive schemas map to JavaScript’s primitive types.
import { z } from "zod";
const nameSchema = z.string();
const ageSchema = z.number();
const activeSchema = z.boolean();
const idSchema = z.bigint();
const dateSchema = z.date();
Each schema has methods for constraints. z.string().min(1).max(100) describes a non-empty string of at most 100 characters. z.number().int().positive() describes a positive integer.
Object schemas
An object schema describes a set of fields with their types.
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
active: z.boolean().default(true),
});
The schema validates that the input is an object and that each field matches its type. The default method provides a value when the field is absent.
Array schemas
An array schema describes a list of items.
const tagsSchema = z.array(z.string().min(1)).max(10);
const usersSchema = z.array(userSchema);
The max constraint applies to the array length, not to each item.
Union and discriminated union
A union schema matches one of several types. A discriminated union selects the branch based on a literal field.
const shapeSchema = z.union([
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("square"), side: z.number() }),
]);
const shapeSchema2 = z.discriminatedUnion("type", [
z.object({ type: z.literal("circle"), radius: z.number() }),
z.object({ type: z.literal("square"), side: z.number() }),
]);
The discriminated union is faster and produces better error messages, because it uses the discriminant to select the branch.
Optional and nullable
optional() allows the field to be absent or undefined. nullable() allows the value to be null. They are different.
const schema = z.object({
nickname: z.string().optional(),
deletedAt: z.date().nullable(),
});
optional means the field may not exist. nullable means it exists but may be null.
The infer utility
z.infer extracts the TypeScript type from a schema.
type User = z.infer<typeof userSchema>;
// {
// id: string;
// email: string;
// name: string;
// age: number;
// active: boolean;
// }
The type is derived from the schema, so it cannot drift. Adding a field to the schema adds it to the type.
b. Parsing and validation
A schema validates data by parsing it. The parse method throws on failure. The safeParse method returns a result object that indicates success or failure without throwing.
The parse method
parse returns the validated value and throws a ZodError on failure.
const user = userSchema.parse(req.body);
// user is typed as User.
// If validation fails, a ZodError is thrown.
The throw must be caught somewhere, either in the handler or in an error middleware.
The safeParse method
safeParse returns { success: true, data } or { success: false, error }. No exception is thrown.
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
const user = result.data;
safeParse is preferred in route handlers because the failure case is handled explicitly and no try/catch is required.
The error structure
A ZodError has an issues array. Each issue has a path (the location in the data), a message, and a code.
const result = userSchema.safeParse({ email: "not-an-email", age: -1 });
if (!result.success) {
console.log(result.error.issues);
// [
// { path: ["email"], message: "Invalid email", code: "invalid_string" },
// { path: ["age"], message: "Number must be greater than 0", code: "too_small" },
// ]
}
The path is an array of keys and indices. It can be joined to produce a dotted path like "profile.address.city".
The flatten method
error.flatten() produces a simpler object with formErrors and fieldErrors.
const flattened = result.error.flatten();
// {
// formErrors: [],
// fieldErrors: {
// email: ["Invalid email"],
// age: ["Number must be greater than 0"],
// },
// }
The flattened form is convenient for form validation responses.
The format method
error.format() produces a nested object that mirrors the shape of the input.
const formatted = result.error.format();
// {
// _errors: [],
// email: { _errors: ["Invalid email"] },
// age: { _errors: ["Number must be greater than 0"] },
// }
The format is useful when the response should reflect the structure of the input.
The custom error message
Each constraint can specify a custom message.
const schema = z.string().min(1, { message: "Name is required" });
The message replaces the default. The errorMap option customizes messages globally.
c. Middleware integration
Validation belongs at the boundary of the route. A middleware validates the request parts and attaches the parsed values to the request object for the handler to use.
The validate middleware
A middleware factory takes a schema per request part and validates each one.
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
interface Schemas {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
function validate(schemas: Schemas) {
return (req: Request, res: Response, next: NextFunction) => {
for (const [key, schema] of Object.entries(schemas)) {
const result = schema.safeParse(req[key as keyof Schemas]);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
(req as any)[key] = result.data;
}
next();
};
}
The parsed value replaces the raw value on the request object. The handler receives the validated, transformed data.
Applying the middleware
The middleware is composed with the route, before the handler.
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
});
app.post("/users",
validate({ body: createUserSchema }),
(req, res) => {
const user = req.body; // Typed and validated.
// ...
});
The route declares what it accepts. The handler does not repeat the checks.
The typed handler
The handler can be typed to receive the parsed value. Express’s Request type is generic over the body, query, and params.
import { Request } from "express";
type CreateUserBody = z.infer<typeof createUserSchema>;
app.post("/users",
validate({ body: createUserSchema }),
(req: Request<{}, {}, CreateUserBody>, res) => {
const { email, name, age } = req.body; // Typed.
});
The type is inferred from the schema, so the handler and the validation cannot drift.
The query parameter coercion
Query parameters arrive as strings. The schema must coerce them to the expected type.
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(["asc", "desc"]).default("desc"),
});
app.get("/posts",
validate({ query: querySchema }),
(req, res) => {
const { page, limit, sort } = req.query; // Numbers and enum.
});
The z.coerce prefix converts the string to the target type before validating.
The response validation
A schema can validate the response before it is sent. This catches bugs where the handler returns data that does not match the API contract.
const userResponseSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
});
const result = userResponseSchema.safeParse(user);
if (!result.success) {
console.error("Response validation failed", result.error);
return res.status(500).json({ error: "internal error" });
}
res.json(result.data);
Response validation is optional but catches a class of bugs that unit tests miss.
Complete Example Session
// ============================================
// PART 1: PRIMITIVE AND OBJECT SCHEMAS
// ============================================
import { z } from "zod";
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150),
active: z.boolean().default(true),
});
// ============================================
// PART 2: TYPE INFERENCE
// ============================================
type User = z.infer<typeof userSchema>;
// ============================================
// PART 3: PARSING
// ============================================
const result = userSchema.safeParse({
id: "123e4567-e89b-12d3-a456-426614174000",
email: "alice@example.com",
name: "Alice",
age: 30,
});
if (result.success) {
const user: User = result.data;
} else {
console.error(result.error.issues);
}
// ============================================
// PART 4: ERROR FORMATTING
// ============================================
const bad = userSchema.safeParse({ email: "nope", age: -1 });
if (!bad.success) {
const flat = bad.error.flatten();
// {
// formErrors: [],
// fieldErrors: {
// id: ["Required"],
// email: ["Invalid email"],
// name: ["Required"],
// age: ["Number must be greater than 0"],
// },
// }
}
// ============================================
// PART 5: EXPRESS VALIDATE MIDDLEWARE
// ============================================
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";
function validate(schemas: {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}) {
return (req: Request, res: Response, next: NextFunction) => {
for (const [key, schema] of Object.entries(schemas)) {
const result = schema.safeParse(req[key as keyof typeof schemas]);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
(req as any)[key] = result.data;
}
next();
};
}
// ============================================
// PART 6: APPLYING VALIDATION
// ============================================
const createUserSchema = userSchema.omit({ id: true });
app.post("/users",
validate({ body: createUserSchema }),
(req, res) => {
const { email, name, age, active } = req.body;
// Insert into database.
res.status(201).json({ email, name, age, active });
});
// ============================================
// PART 7: QUERY COERCION
// ============================================
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(["asc", "desc"]).default("desc"),
});
app.get("/posts",
validate({ query: listQuerySchema }),
(req, res) => {
const { page, limit, sort } = req.query;
res.json({ page, limit, sort });
});
// ============================================
// PART 8: TRANSFORMATION
// ============================================
const signupSchema = z.object({
email: z.string().email().transform((e) => e.toLowerCase().trim()),
name: z.string().min(1).transform((n) => n.trim()),
password: z.string().min(12),
});
// ============================================
// PART 9: DISCRIMINATED UNION
// ============================================
const eventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
z.object({ type: z.literal("keypress"), key: z.string() }),
]);
// ============================================
// PART 10: RESPONSE VALIDATION
// ============================================
const userResponseSchema = z.object({
id: z.string().uuid(),
email: z.string(),
name: z.string(),
});
app.get("/users/:id", async (req, res) => {
const user = await db.users.findByPk(req.params.id);
const result = userResponseSchema.safeParse(user);
if (!result.success) {
console.error("Response validation failed", result.error.flatten());
return res.status(500).json({ error: "internal error" });
}
res.json(result.data);
});
The ten parts showed primitive and object schemas, type inference, parsing, error formatting, the Express middleware, applying validation, query coercion, transformation, discriminated unions, and response validation. The same user schema ran through every part, showing how one definition serves validation, types, and transformation.
Quick Reference
Common Schema Types
| Schema | Validates |
|---|---|
z.string() | Strings |
z.number() | Numbers |
z.boolean() | Booleans |
z.date() | Date objects |
z.array(s) | Arrays of s |
z.object({...}) | Objects with fields |
z.enum([...]) | String literal unions |
z.literal(v) | Exact value |
z.union([...]) | One of several |
z.discriminatedUnion(k, [...]) | Tagged union |
Common Constraints
| Constraint | Applies To |
|---|---|
.min(n) / .max(n) | Strings, numbers, arrays |
.email() | Strings |
.uuid() | Strings |
.url() | Strings |
.regex(r) | Strings |
.int() | Numbers |
.positive() / .negative() | Numbers |
.optional() | Any |
.nullable() | Any |
.default(v) | Any |
.transform(fn) | Any |
Parse Methods
| Method | Behavior |
|---|---|
.parse(data) | Returns value or throws |
.safeParse(data) | Returns { success, data | error } |
.parseAsync(data) | For async refinements |
Error Methods
| Method | Output |
|---|---|
.issues | Array of issues with path, message, code |
.flatten() | { formErrors, fieldErrors } |
.format() | Nested object mirroring input |
.toString() | Human-readable string |
Best Practices
✅ Do This:
// Define the schema once and infer the type.
const schema = z.object({ email: z.string().email() });
type Payload = z.infer<typeof schema>; // ✅
// Use safeParse in route handlers.
const result = schema.safeParse(req.body); // ✅
// Coerce query parameters.
page: z.coerce.number().int().min(1) // ✅
// Transform at the boundary.
email: z.string().email().transform((e) => e.toLowerCase()) // ✅
// Use discriminated unions for tagged types.
z.discriminatedUnion("type", [...]) // ✅
// Return structured errors.
res.status(400).json({ errors: result.error.flatten() }); // ✅
// Validate responses in development.
if (!result.success) console.error("Response validation failed"); // ✅
❌ Don’t Do This:
// Don't trust TypeScript types at runtime.
function handler(body: User) { /* body may be anything */ } // ❌
// Don't scatter validation across the handler.
if (!req.body.email) return res.status(400); // ❌ Use a schema
// Don't use parse in handlers without a try/catch.
const user = schema.parse(req.body); // ❌ Throws
// Don't skip coercion for query parameters.
page: z.number() // ❌ Query values are strings
// Don't forget the default for optional fields.
active: z.boolean() // ❌ Missing field fails
// Don't reuse a schema across incompatible contexts.
// Create a new schema with .omit() or .pick(). // ❌
// Don't trust the input after validation in one place.
// Validate at every boundary. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Query params fail | Values are strings | Use z.coerce |
| Missing field fails | No default | Add .default() |
| Type drift | Separate type and schema | Use z.infer |
| Unhandled exception | parse throws | Use safeParse |
| Error format inconsistent | Hand-written responses | Use flatten() |
| Optional vs nullable | Confused semantics | optional = may be absent, nullable = may be null |
| Union order wrong | First match wins | Use discriminatedUnion |
| Transformation lost | Schema not reused | Use the parsed value |
Real-World Examples
1. Primitive String
z.string().min(1).max(100)
2. Email Field
z.string().email().transform((e) => e.toLowerCase())
3. Coerced Query Parameter
page: z.coerce.number().int().min(1).default(1)
4. Object Schema
const schema = z.object({ email: z.string().email(), age: z.number().int() })
5. Type Inference
type User = z.infer<typeof userSchema>
6. Safe Parse
const result = schema.safeParse(data)
7. Flattened Errors
result.error.flatten()
8. Discriminated Union
z.discriminatedUnion("type", [/* ... */])
9. Validation Middleware
app.post("/users", validate({ body: schema }), handler)
10. Response Validation
const result = responseSchema.safeParse(user)
Visual
The Boundary
┌────────────────────────────────────────────────────────────┐
│ VALIDATION AT THE BOUNDARY │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ NETWORK │ │
│ │ Untyped JSON, strings, arrays │ │
│ │ TypeScript has no opinion │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ BOUNDARY: ZOD VALIDATION │ │
│ │ schema.safeParse(req.body) │ │
│ │ → success: typed, transformed data │ │
│ │ → failure: structured errors │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ v │
│ ┌──────────────────────────────────────────────────┐ │
│ │ APPLICATION │ │
│ │ Typed values, no further checks │ │
│ │ TypeScript knows the shape │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Validate once, at the edge. Trust the data after. │
└────────────────────────────────────────────────────────────┘
Schema to Type
┌────────────────────────────────────────────────────────────┐
│ ONE DEFINITION, THREE USES │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ const userSchema = z.object({ │ │
│ │ id: z.string().uuid(), │ │
│ │ email: z.string().email(), │ │
│ │ age: z.number().int().min(0), │ │
│ │ }); │ │
│ └────────────────────────┬─────────────────────────┘ │
│ │ │
│ ┌─────────────────┼─────────────────┐ │
│ │ │ │ │
│ v v v │
│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │
│ │ VALIDATE │ │ INFER │ │ TRANSFORM │ │
│ │ │ │ │ │ │ │
│ │ safeParse │ │ z.infer │ │ .parse() │ │
│ │ returns │ │ returns │ │ returns │ │
│ │ data or │ │ type │ │ final │ │
│ │ errors │ │ │ │ value │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ The schema is the source of truth. │
│ The type, the validation, and the transformation all │
│ derive from the same definition. │
└────────────────────────────────────────────────────────────┘
Parse vs SafeParse
┌────────────────────────────────────────────────────────────┐
│ parse vs safeParse │
│ │
│ parse │
│ ┌──────────────────────────────────────────────────┐ │
│ │ const user = schema.parse(data); │ │
│ │ │ │
│ │ Success → returns the value │ │
│ │ Failure → throws ZodError │ │
│ │ │ │
│ │ Requires try/catch at the call site. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ safeParse │
│ ┌──────────────────────────────────────────────────┐ │
│ │ const result = schema.safeParse(data); │ │
│ │ │ │
│ │ Success → { success: true, data } │ │
│ │ Failure → { success: false, error } │ │
│ │ │ │
│ │ No exception. Failure is handled explicitly. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ safeParse is preferred in route handlers. │
└────────────────────────────────────────────────────────────┘
Error Format
┌────────────────────────────────────────────────────────────┐
│ STRUCTURED ERRORS │
│ │
│ Input: { email: "nope", age: -1 } │
│ │
│ issues: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ [ │ │
│ │ { path: ["email"], │ │
│ │ message: "Invalid email", │ │
│ │ code: "invalid_string" }, │ │
│ │ { path: ["age"], │ │
│ │ message: "Number must be > 0", │ │
│ │ code: "too_small" } │ │
│ │ ] │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ flatten(): │
│ ┌──────────────────────────────────────────────────┐ │
│ │ { │ │
│ │ formErrors: [], │ │
│ │ fieldErrors: { │ │
│ │ email: ["Invalid email"], │ │
│ │ age: ["Number must be > 0"] │ │
│ │ } │ │
│ │ } │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ The path is an array. Join with "." for dotted paths. │
│ The message is human-readable. │
│ The code is machine-readable for custom handling. │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Zod | TypeScript-first schema validation library |
| Schema | A value describing the expected shape |
z.infer | Extracts the TypeScript type from a schema |
parse | Returns value or throws |
safeParse | Returns { success, data | error } |
coerce | Converts strings to numbers/booleans |
transform | Modifies the value after validation |
flatten | Simplified error format |
| Discriminated union | Tagged union with fast dispatch |
| Middleware | Validates request parts at the boundary |
Key takeaways:
- TypeScript types are erased at runtime. A function typed to accept
{ email: string }will receive{ email: 42 }from the network. Validation belongs at the boundary, and it must happen at runtime. - One schema serves three purposes. It validates at runtime, it infers the TypeScript type, and it parses and transforms the data. The type cannot drift from the validation because both come from the same definition.
- Use
safeParsein route handlers.parsethrows, which requires a try/catch and often produces inconsistent error responses.safeParsereturns a result object that the handler can process explicitly. - Coerce query parameters. Query values are strings.
z.coerce.number()converts them before validating. Without coercion,z.number()fails on every query parameter. - Transform at the boundary. Trimming whitespace, lowercasing email addresses, and parsing dates should happen in the schema. The handler receives the final value, not the raw input.
- Use discriminated unions for tagged types. A union of objects with a
typefield is faster and produces better errors when expressed as a discriminated union. - Return structured errors.
error.flatten()produces a consistent shape withformErrorsandfieldErrors. The API response format is uniform across routes. - Validate at every boundary. HTTP requests, message queue payloads, environment variables, and third-party API responses are all untrusted. Each boundary needs its own schema.
Remember: Runtime validation is the step that bridges the gap between the untyped network and the typed application. TypeScript provides types at compile time; Zod provides schemas at runtime. The schema is defined once and serves as the source of truth for validation, type inference, and transformation. safeParse returns a result object that the handler processes explicitly, avoiding the try/catch scatter that parse requires. Coercion converts strings to their expected types, and transformation normalizes the data before it reaches the application. The middleware applies the schema at the boundary, so the handler receives validated, typed values and does not repeat the checks. The result is a codebase where the type, the validation, and the transformation cannot drift, and where every boundary is checked in one place.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!