| |

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

SchemaValidates
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

ConstraintApplies 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

MethodBehavior
.parse(data)Returns value or throws
.safeParse(data)Returns { success, data | error }
.parseAsync(data)For async refinements

Error Methods

MethodOutput
.issuesArray 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

PitfallWhy It HappensFix
Query params failValues are stringsUse z.coerce
Missing field failsNo defaultAdd .default()
Type driftSeparate type and schemaUse z.infer
Unhandled exceptionparse throwsUse safeParse
Error format inconsistentHand-written responsesUse flatten()
Optional vs nullableConfused semanticsoptional = may be absent, nullable = may be null
Union order wrongFirst match winsUse discriminatedUnion
Transformation lostSchema not reusedUse 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

ItemValue
ZodTypeScript-first schema validation library
SchemaA value describing the expected shape
z.inferExtracts the TypeScript type from a schema
parseReturns value or throws
safeParseReturns { success, data | error }
coerceConverts strings to numbers/booleans
transformModifies the value after validation
flattenSimplified error format
Discriminated unionTagged union with fast dispatch
MiddlewareValidates 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 safeParse in route handlers. parse throws, which requires a try/catch and often produces inconsistent error responses. safeParse returns 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 type field is faster and produces better errors when expressed as a discriminated union.
  • Return structured errors. error.flatten() produces a consistent shape with formErrors and fieldErrors. 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!