Skip to content
Learni
View all tutorials
TypeScript

How to Validate Data with Zod in 2026

Lire en français

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

terminal
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/node

We 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

primitives.ts
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

user-schema.ts
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

advanced-schema.ts
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

api-server.ts
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/users

Creates 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

transform-schema.ts
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 safeParse in production: Avoids crashes on malicious inputs.
  • Custom error messages: { message: 'Email required' } for better UX.
  • Infer types everywhere: z.infer for 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