Introduction
In 2026, data validation remains a cornerstone of any robust project, especially with the rise of REST APIs and full-stack TypeScript apps. Zod, the most popular library for this, shines with its simplicity and automatic type inference: a Zod schema instantly generates a matching TypeScript type, eliminating duplication and typing errors.
Why does Zod outperform alternatives like Yup or Joi? It's zero dependencies, ultra-lightweight (10kB gzipped), and integrates seamlessly with Next.js, React, or Node.js. Imagine validating a user form in one line, with custom error messages, no boilerplate. This beginner tutorial takes you from basics to advanced cases: installation, primitive schemas, complex objects, unions, and practical integrations.
By the end, you'll secure your data like a pro, avoiding SQL injections or malicious payloads. Ready to level up your code?
Prerequisites
- Node.js 20+ installed
- Basic TypeScript knowledge (primitive types, objects)
- An editor like VS Code with TypeScript extension
- npm or yarn for packages
Installing Zod
mkdir zod-validation-demo
cd zod-validation-demo
npm init -y
npm install zod
tsx --version || npm install -g tsx
npm install -D typescript @types/nodeWe create a minimal Node.js project, install Zod as a dependency, and tsx to run TS directly (faster than tsc). TypeScript is a devDep for editing. Run tsx index.ts to test the following scripts.
First Schema: Basic Validation
Let's start with primitives. Zod uses a fluent API: z.string(), z.number(), etc. The .parse() method validates and throws an error if invalid, while .safeParse() returns a result without crashing.
Analogy: Like a bouncer checking your ID—strict but polite.
Simple Primitive Schema
import { z } from 'zod';
const EmailSchema = z.string().email({ message: 'Email invalide' });
const AgeSchema = z.number().min(18, { message: 'Âge minimum 18 ans' }).max(120);
const result1 = EmailSchema.safeParse('user@example.com');
const result2 = AgeSchema.safeParse('25');
const result3 = AgeSchema.safeParse(17);
console.log('Email valide:', result1.success);
console.log('Âge 25 valide:', result2.success);
console.log('Âge 17:', result3.error?.issues[0].message);This script validates an email and age with custom messages. safeParse avoids crashes in production. TypeScript inference: EmailSchema becomes a typed string. Run with tsx primitives.ts to see: valid email, age 25 OK, error on 17.
Validating Structured Objects
For forms or JSON APIs, z.object() combines schemas. Add .strict() to reject extra props, or .passthrough() to ignore them. Magic inference: UserSchema infers the full type.
User Object Schema
import { z } from 'zod';
type User = z.infer<typeof UserSchema>; // Inférence auto
const UserSchema = z.object({
name: z.string().min(2, 'Nom trop court'),
email: z.string().email('Email invalide'),
age: z.number().int().min(18),
isAdmin: z.boolean().default(false),
});
const userData = {
name: 'Alice',
email: 'alice@test.com',
age: 28,
isAdmin: true,
extraProp: 'ignore' // Sera rejeté en strict
};
const result = UserSchema.strict().safeParse(userData);
if (result.success) {
console.log('Utilisateur valide:', result.data);
} else {
console.log('Erreurs:', result.error.format());
}Defines a User schema with defaults and strict mode (rejects extraProp). z.infer creates the TS type. error.format() provides readable output. Perfect for APIs: validates and types payloads in one call.
Advanced Schemas: Arrays, Unions, Optionals
Handle lists (z.array()), choices (z.union()), and optional fields (.optional()). Analogy: Like a restaurant menu—pick appetizer OR main, or nothing.
Complex Schemas with Arrays and Unions
import { z } from 'zod';
const RoleSchema = z.union([z.literal('admin'), z.literal('user'), z.literal('guest')]);
const TagsSchema = z.array(z.string().min(1)).max(5);
const PostSchema = z.object({
title: z.string().min(5),
content: z.string(),
role: RoleSchema,
tags: TagsSchema.optional(),
published: z.boolean().default(false),
});
type Post = z.infer<typeof PostSchema>;
const postData = {
title: 'Mon premier post',
content: 'Contenu détaillé...',
role: 'admin' as const,
tags: ['tech', 'zod'],
};
const result = PostSchema.safeParse(postData);
console.log(result.success ? 'Post OK' : result.error.issues);Combines union for enum-like roles, limited array, and optional fields. z.literal for exact values. Infers Post with tags?: string[]. Ideal for blogs or dashboards: validates varied payloads effortlessly.
Integrating with an Express API
Zod excels in the backend. Use z.object().parse(req.body) in middleware to automatically type handlers.
Zod Middleware for API
import express from 'express';
import cors from 'cors';
import { z } from 'zod';
const app = express();
app.use(cors());
app.use(express.json());
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
app.post('/users', (req, res) => {
try {
const userData = CreateUserSchema.parse(req.body);
// Simule DB save
res.json({ success: true, user: userData });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ errors: error.errors });
}
res.status(500).json({ error: 'Erreur serveur' });
}
});
app.listen(3000, () => console.log('Serveur sur http://localhost:3000'));
// Test: curl -X POST -H "Content-Type: application/json" -d '{"name":"Bob","email":"bob@test.com"}' http://localhost:3000/usersCreates a secure POST endpoint. parse() throws if invalid, caught for 400 response. Add npm i express cors @types/express @types/cors -D typescript. Run tsx api-server.ts, test with curl. Inferred types for req.body!
Transformations and Refinements
Zod supports .transform() for normalization (e.g., string to number) and .refine() for custom validations.
Custom Transform and Refine
import { z } from 'zod';
const PhoneSchema = z.string().transform((val) => parseInt(val.replace(/\D/g, '')))
.pipe(z.number().min(1000000000, 'Numéro trop court'));
const ProfileSchema = z.object({
phone: PhoneSchema,
score: z.number().refine((val) => val >= 0 && val <= 100, {
message: 'Score entre 0 et 100',
}),
});
const data = { phone: '+33 1 23 45 67 89', score: 85 };
const result = ProfileSchema.safeParse(data);
console.log(result.success ? `Phone: ${result.data.phone}, Score: ${result.data.score}` : result.error.issues);.transform() cleans the phone to a number, .pipe() chains validations. .refine() for business logic. Output: Phone: 33123456789, Score:85. Powerful for sanitizing user inputs.
Best Practices
- Always use
safeParsein production: Avoids crashes on malicious inputs. - Custom error messages:
{ message: 'Email required' }for better UX. - Infer types everywhere:
z.inferfor zero duplication. - Reusable schemas: Export them in a separate module for frontend/backend sharing.
- Combine with tRPC or React Hook Form: Native Zod resolvers for full-stack apps.
Common Mistakes to Avoid
- Forgetting
.strict(): Allows unwanted props, opening injection risks. - Using
parse()without try/catch: Crashes the app on bad input. - Ignoring TS inference: Manually duplicating types = maintenance nightmare.
- No
.optional()on arrays: Fails on undefined; use.default([])instead.
Next Steps
- Official docs: zod.dev
- Integrate with Next.js App Router: Next.js + Zod Tutorial
- Advanced: Zod + SuperJSON for DB queries
- Check our advanced TypeScript courses to master Zod in production.