Episode 3 — NodeJS MongoDB Backend Architecture / 3.10 — Input Validation
3.10.c — Zod Validation
Zod is a TypeScript-first schema validation library that provides static type inference, composable schemas, and zero-dependency validation for any JavaScript runtime.
< 3.10.b — Express Validator | 3.10.d — Error Response Format >
1. What Is Zod?
Zod is a schema declaration and validation library designed for TypeScript. Unlike express-validator, which is tied to Express middleware, Zod schemas are framework-agnostic — they work in Express, Fastify, Next.js, or even the browser.
Key advantages:
- Type inference — derive TypeScript types directly from schemas
- Composable — build complex schemas from simple ones
- Zero dependencies — lightweight (~13KB)
- Framework-agnostic — works anywhere JavaScript runs
2. Installing
npm install zod
// CommonJS
const { z } = require('zod');
// ESM / TypeScript
import { z } from 'zod';
3. Basic Schemas
Every Zod schema starts with z. followed by the type:
const { z } = require('zod');
// Primitive types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const bigintSchema = z.bigint();
// Parsing
stringSchema.parse("hello"); // "hello" (returns the value)
stringSchema.parse(123); // throws ZodError
numberSchema.parse(42); // 42
numberSchema.parse("42"); // throws ZodError (no coercion by default)
// Coercion — converts input to the target type
const coercedNumber = z.coerce.number();
coercedNumber.parse("42"); // 42
coercedNumber.parse("abc"); // throws ZodError (NaN is not a valid number)
const coercedBoolean = z.coerce.boolean();
coercedBoolean.parse("true"); // true
coercedBoolean.parse(1); // true
coercedBoolean.parse(0); // false
Object Schemas
const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
age: z.number().int().positive(),
isActive: z.boolean(),
});
// Valid
UserSchema.parse({
name: "Alice",
email: "alice@example.com",
age: 25,
isActive: true,
});
// Invalid — throws ZodError with detailed messages
UserSchema.parse({
name: "", // empty string is still valid for z.string()
email: "not-email", // fails .email()
age: -5, // fails .positive()
isActive: "yes", // not a boolean
});
4. Refinements
Refinements add constraints to base types:
// String refinements
const emailSchema = z.string().email("Invalid email address");
const urlSchema = z.string().url("Must be a valid URL");
const uuidSchema = z.string().uuid("Must be a valid UUID");
const minStr = z.string().min(3, "At least 3 characters");
const maxStr = z.string().max(100, "At most 100 characters");
const lengthStr = z.string().length(10, "Must be exactly 10 characters");
const regexStr = z.string().regex(/^[A-Z]{3}-\d{4}$/, "Format: ABC-1234");
const trimmed = z.string().trim(); // trims whitespace
const lower = z.string().toLowerCase(); // converts to lowercase
const starts = z.string().startsWith("https://");
const ends = z.string().endsWith(".com");
const includes = z.string().includes("@");
const datetimeStr = z.string().datetime(); // ISO 8601 datetime
// Number refinements
const positiveNum = z.number().positive("Must be positive");
const nonNegative = z.number().nonnegative("Must be >= 0");
const intNum = z.number().int("Must be an integer");
const rangeNum = z.number().min(0).max(100);
const multipleOf = z.number().multipleOf(5); // 0, 5, 10, 15...
const finite = z.number().finite(); // no Infinity
// Combining refinements
const priceSchema = z.number()
.positive("Price must be positive")
.multipleOf(0.01)
.max(999999.99, "Price too high");
const passwordSchema = z.string()
.min(8, "At least 8 characters")
.max(128, "At most 128 characters")
.regex(/[A-Z]/, "Must contain an uppercase letter")
.regex(/[a-z]/, "Must contain a lowercase letter")
.regex(/[0-9]/, "Must contain a number")
.regex(/[^A-Za-z0-9]/, "Must contain a special character");
5. Optional and Nullable
// Optional — field can be undefined or missing
const schema1 = z.object({
name: z.string(),
bio: z.string().optional(), // string | undefined
});
schema1.parse({ name: "Alice" }); // valid (bio is undefined)
schema1.parse({ name: "Alice", bio: "Hello" }); // valid
schema1.parse({ name: "Alice", bio: null }); // INVALID (null !== undefined)
// Nullable — field can be null
const schema2 = z.object({
name: z.string(),
bio: z.string().nullable(), // string | null
});
schema2.parse({ name: "Alice", bio: null }); // valid
schema2.parse({ name: "Alice" }); // INVALID (bio is required)
// Both — field can be undefined, null, or a string
const schema3 = z.object({
bio: z.string().optional().nullable(), // string | undefined | null
});
// Default values
const schema4 = z.object({
role: z.string().default("user"),
page: z.number().default(1),
});
schema4.parse({}); // { role: "user", page: 1 }
6. Arrays and Nested Objects
// Arrays
const tagsSchema = z.array(z.string());
tagsSchema.parse(["js", "node"]); // valid
tagsSchema.parse([1, 2, 3]); // invalid
// Array constraints
const limitedArray = z.array(z.string())
.min(1, "At least one item")
.max(10, "At most 10 items")
.nonempty("Cannot be empty");
// Nested objects
const AddressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().default("US"),
});
const UserSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(13).max(120),
address: AddressSchema,
tags: z.array(z.string().max(30)).max(10).default([]),
socialLinks: z.object({
twitter: z.string().url().optional(),
github: z.string().url().optional(),
linkedin: z.string().url().optional(),
}).optional(),
});
// Enums
const RoleSchema = z.enum(["user", "admin", "moderator"]);
RoleSchema.parse("admin"); // "admin"
RoleSchema.parse("owner"); // throws ZodError
// Union types
const IdSchema = z.union([z.string().uuid(), z.number().int().positive()]);
IdSchema.parse("550e8400-e29b-41d4-a716-446655440000"); // valid
IdSchema.parse(42); // valid
IdSchema.parse("not-a-uuid"); // invalid
7. parse() vs safeParse()
const UserSchema = z.object({
email: z.string().email(),
age: z.number().min(13),
});
// parse() — throws on failure
try {
const user = UserSchema.parse({ email: "bad", age: 5 });
} catch (error) {
// error is a ZodError
console.log(error.issues);
// [
// { code: 'invalid_string', message: 'Invalid email', path: ['email'] },
// { code: 'too_small', message: 'Number must be >= 13', path: ['age'] }
// ]
}
// safeParse() — returns result object (RECOMMENDED for APIs)
const result = UserSchema.safeParse({ email: "bad", age: 5 });
if (!result.success) {
console.log(result.error.issues);
// Same array of issues
console.log(result.error.flatten());
// {
// formErrors: [],
// fieldErrors: {
// email: ['Invalid email'],
// age: ['Number must be greater than or equal to 13']
// }
// }
} else {
console.log(result.data); // Typed, validated data
}
| Method | On Success | On Failure | Use When |
|---|---|---|---|
parse() | Returns data | Throws ZodError | You want try/catch flow |
safeParse() | { success: true, data } | { success: false, error } | You want conditional flow |
8. Express Middleware with Zod
// middleware/zodValidate.js
const { z } = require('zod');
const zodValidate = (schema) => (req, res, next) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success) {
const errors = result.error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}));
return res.status(400).json({ success: false, errors });
}
// Replace req properties with validated (and transformed) data
req.body = result.data.body;
req.query = result.data.query;
req.params = result.data.params;
next();
};
module.exports = zodValidate;
// schemas/userSchemas.js
const { z } = require('zod');
const registerSchema = z.object({
body: z.object({
email: z.string().email("Invalid email"),
password: z.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Needs uppercase letter")
.regex(/[0-9]/, "Needs a number"),
name: z.string().min(2).max(50).trim(),
age: z.number().int().min(13).max(120).optional(),
}),
query: z.object({}),
params: z.object({}),
});
const getUserSchema = z.object({
body: z.object({}),
query: z.object({}),
params: z.object({
id: z.string().regex(/^[0-9a-fA-F]{24}$/, "Invalid MongoDB ObjectId"),
}),
});
module.exports = { registerSchema, getUserSchema };
// routes/userRoutes.js
const zodValidate = require('../middleware/zodValidate');
const { registerSchema, getUserSchema } = require('../schemas/userSchemas');
router.post('/register', zodValidate(registerSchema), userController.register);
router.get('/users/:id', zodValidate(getUserSchema), userController.getUser);
9. Zod vs express-validator Comparison
| Feature | express-validator | Zod |
|---|---|---|
| TypeScript types | No (manual) | Yes (z.infer) |
| Framework | Express only | Any framework |
| API style | Chained middleware | Schema objects |
| Async validation | Built-in .custom() | .refine() with async |
| Sanitization | Built-in (trim, escape) | .transform() |
| Bundle size | ~50KB | ~13KB |
| Learning curve | Low (Express devs) | Low (TS devs) |
| Schema reuse | Export arrays | Export schemas + types |
| Error format | Custom formatting | .flatten(), .format() |
| Community | Mature, large | Growing rapidly |
| Best for | Express-only projects | TypeScript, multi-framework |
10. Type Inference with z.infer
One of Zod's killer features — derive TypeScript types from schemas:
import { z } from 'zod';
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(50),
age: z.number().int().min(13).optional(),
role: z.enum(['user', 'admin']).default('user'),
tags: z.array(z.string()).default([]),
});
// Derive the TypeScript type — no manual interface needed
type User = z.infer<typeof UserSchema>;
// Equivalent to:
// type User = {
// email: string;
// name: string;
// age?: number | undefined;
// role: "user" | "admin";
// tags: string[];
// }
// Use in your controller
const createUser = (data: User) => {
// data is fully typed
console.log(data.email); // TypeScript knows this is a string
console.log(data.age); // TypeScript knows this is number | undefined
};
// Input type (before defaults) vs Output type (after defaults)
type UserInput = z.input<typeof UserSchema>;
// role is optional (before .default())
type UserOutput = z.output<typeof UserSchema>;
// role is required "user" | "admin" (after .default())
11. Transform and Preprocess
// Transform — change the output after validation
const CentsSchema = z.number().transform(dollars => Math.round(dollars * 100));
CentsSchema.parse(19.99); // 1999
// Transform with object schemas
const UserInputSchema = z.object({
email: z.string().email().transform(e => e.toLowerCase()),
name: z.string().transform(n => n.trim()),
birthDate: z.string().datetime().transform(d => new Date(d)),
});
// Preprocess — transform BEFORE validation
const NumberFromString = z.preprocess(
(val) => (typeof val === 'string' ? Number(val) : val),
z.number().min(0)
);
NumberFromString.parse("42"); // 42 (string -> number -> validated)
NumberFromString.parse("abc"); // throws (string -> NaN -> fails z.number())
// Practical: parse query params (always strings) into numbers
const PaginationSchema = z.object({
page: z.preprocess(Number, z.number().int().min(1)).default(1),
limit: z.preprocess(Number, z.number().int().min(1).max(100)).default(20),
});
// Custom refinement — complex validation logic
const DateRangeSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(
(data) => new Date(data.startDate) < new Date(data.endDate),
{ message: "End date must be after start date", path: ["endDate"] }
);
// Async refinement — database checks
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await User.findOne({ email });
return !exists;
},
{ message: "Email already registered" }
);
// Must use parseAsync() or safeParseAsync() with async refinements
const result = await UniqueEmailSchema.safeParseAsync("test@example.com");
12. Complete Example: API with Zod
const express = require('express');
const { z } = require('zod');
const app = express();
app.use(express.json());
// ---- Schemas ----
const CreateProductSchema = z.object({
name: z.string().min(1, "Name required").max(200).trim(),
price: z.number().positive("Price must be positive").multipleOf(0.01),
description: z.string().max(5000).optional(),
category: z.enum(["electronics", "clothing", "books", "food", "other"]),
tags: z.array(z.string().max(30)).max(10).default([]),
stock: z.number().int().nonnegative().default(0),
dimensions: z.object({
width: z.number().positive(),
height: z.number().positive(),
depth: z.number().positive(),
unit: z.enum(["cm", "in"]).default("cm"),
}).optional(),
});
// ---- Middleware ----
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const formatted = result.error.flatten();
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Input validation failed',
details: formatted.fieldErrors,
},
});
}
req.validatedBody = result.data;
next();
};
// ---- Route ----
app.post('/products', validate(CreateProductSchema), (req, res) => {
// req.validatedBody is fully validated and typed
const product = req.validatedBody;
// Save to database...
res.status(201).json({ success: true, data: product });
});
app.listen(3000);
Key Takeaways
- Zod schemas are framework-agnostic — use them in Express, Next.js, or the browser
safeParse()is preferred overparse()for API validation (no try/catch needed)z.infer<typeof Schema>derives TypeScript types from schemas automatically.transform()modifies output after validation;.preprocess()modifies input before.refine()adds custom validation logic, including async database checks- Compose schemas — build complex schemas from simple reusable pieces
.flatten()formats errors into field-based maps ideal for frontend forms
Explain-It Challenge
You are designing a shared validation layer for a full-stack TypeScript application (React frontend + Express backend). The same data schemas need to validate form inputs on the client and request bodies on the server. Explain how Zod enables this "single source of truth" pattern, write a complete set of schemas for a blog post creation form (title, content, tags, category, publishDate, isDraft), and show how to use
z.inferto generate types used by both the React form component and the Express route handler.