Skip to main content
Mohammad Shehadeh — home (MSH monogram, letter M filled with the Palestinian flag)

Data Validation with Zod

Published on
Last updated on
9 min read
1import { z } from 'zod';
2
3export const FormSchema = z.object({
4  id: z.string(),
5  customerId: z.string({
6      invalid_type_error: 'Please select a customer.',
7  }),
8  amount: z.coerce.number().gt(0, { message: 'Please enter an amount greater than $0.' }),
9  status: z.enum(['pending', 'paid'], {
10      invalid_type_error: 'Please select an invoice status.',
11  }),
12  date: z.string(),
13});

This is a typical form validation schema in Zod. Here's each field:

  • id: A simple string field with no additional constraints
  • customerId: A string field with a custom error message when the wrong type is provided
  • amount: Uses z.coerce.number() to automatically convert string inputs (like from HTML forms) to numbers, then validates it's greater than 0
  • status: An enum that only accepts specific values ('pending' or 'paid'), perfect for dropdown selections
  • date: A string field for date input (you might transform this to a Date object later)

The win here: you get both runtime validation and TypeScript types from a single schema.

What is Zod?

Zod is a TypeScript-first schema validation library with static type inference. It checks that your data matches the expected shape and types at runtime, with strong TypeScript integration.

The goal is to remove duplicate type declarations. You declare a validator once, and Zod infers the static TypeScript type. So you don't maintain separate type definitions and runtime validations.

Requirements:

  • TypeScript v5.5 or later
  • "strict": true must be enabled in your tsconfig.json

The current stable version is Zod 4, which adds JSON Schema conversion and richer metadata support.

Key Features:

  • Zero dependencies
  • Works in Node.js and all modern browsers
  • Tiny: 2kb core bundle (gzipped)
  • Immutable: methods (e.g. .optional()) return a new instance
  • Concise, chainable interface
  • Functional approach: parse, don't validate
  • Works with plain JavaScript too! You don't need to use TypeScript
  • Rich error messages and customizable error handling
  • Extensive validation primitives out of the box
  • JSON Schema conversion support (Zod 4+)
  • Metadata support for additional schema information

Core Features and Usage

Basic Schema Definition and Validation

1import { z } from 'zod';
2
3// Define a basic schema
4const UserSchema = z.object({
5  name: z.string(),
6  age: z.number(),
7  email: z.email(),
8  isActive: z.boolean(),
9});
10
11// Parsing data (throws on invalid data)
12try {
13  const user = UserSchema.parse({
14      name: 'John',
15      age: 30,
16      email: 'john@example.com',
17      isActive: true,
18  });
19} catch (error) {
20  console.error('Validation failed:', error);
21}
22
23// Safe parsing (returns success/error object)
24const result = UserSchema.safeParse(data);
25if (result.success) {
26  // Type is automatically inferred
27  const validatedData = result.data;
28} else {
29  // Handle ZodError
30  console.log(result.error.errors);
31}

Understanding Parse vs SafeParse:

  • parse(): Throws on failure. Use it when you want exceptions to bubble up and stop execution.
  • safeParse(): Returns a result object — either success: true with the validated data, or success: false with error details. Safer for user-facing apps where you want to handle errors gracefully.

Email validation checks the email format for you, and all types are enforced at runtime with TypeScript intellisense.

Type Inference

Zod automatically infers TypeScript types from your schemas:

1import { z } from 'zod';
2
3const UserSchema = z.object({
4  id: z.uuid(),
5  name: z.string(),
6  age: z.number().optional(),
7});
8
9// Extract the TypeScript type
10type User = z.infer<typeof UserSchema>;
11// Equivalent to:
12// type User = {
13  // id: string;
14  // name: string;
15  // age?: number;
16// }

This is one of Zod's most powerful features. You define the schema once, and TypeScript types are generated for you. Notice how .optional() translates to age?: number in the type. You no longer maintain separate type definitions and validation schemas, which cuts duplication and inconsistencies.

Advanced Validation with Refinements

Zod offers two methods for custom validation: refine and superRefine:

1// Basic refinement
2const PasswordSchema = z
3  .string()
4  .min(8)
5  .refine((password) => /[A-Z]/.test(password), {
6      message: 'Password must contain at least one uppercase letter',
7  });
8
9// Complex refinement with custom error handling
10const FormSchema = z
11  .object({
12      password: z.string(),
13      confirm: z.string(),
14  })
15  .superRefine((data, ctx) => {
16      if (data.password !== data.confirm) {
17          ctx.addIssue({
18              code: z.ZodIssueCode.custom,
19              message: "Passwords don't match",
20              path: ['confirm'], // Path of the error
21          });
22      }
23  });

When to Use Refine vs SuperRefine:

  • refine(): Best for simple, single-field validations or one custom rule. The function returns a boolean.
  • superRefine(): For validations across multiple fields, custom error paths, or fine-grained control over error messages and locations.

In the password example, refine() validates one field against a pattern. In the form example, superRefine() compares two fields and assigns the error to a specific field path — useful for form libraries that need to know which field failed.

Schema Composition and Extension

Zod makes composing and extending schemas easy:

1// Base schema
2const BaseSchema = z.object({
3  id: z.uuid(),
4  createdAt: z.date(),
5  updatedAt: z.date(),
6});
7
8// Extended schema
9const UserSchema = BaseSchema.extend({
10  username: z.string().min(3),
11  email: z.email(),
12  settings: z.object({
13      theme: z.enum(['light', 'dark']),
14      notifications: z.boolean(),
15  }),
16});
17
18// Partial schema (all fields optional)
19const PartialUserSchema = UserSchema.partial();
20
21// Deep partial (nested fields also optional)
22const DeepPartialUserSchema = UserSchema.deepPartial();

Schema Composition Explained:

  • extend(): Adds new fields to an existing schema. Good for specialized versions of a base schema.
  • partial(): Makes all top-level fields optional. Useful for updates where you change only some fields.
  • deepPartial(): Makes all fields optional recursively, including nested ones. Ideal for partial updates of complex nested data.

This promotes reuse and keeps related schemas consistent. For example, you might have a base EntitySchema with common fields like id, createdAt, updatedAt that you extend for entities like User, Product, etc.

Transform and Preprocess

Zod can transform data during validation:

1const StringToNumberSchema = z.string().transform((str) => parseInt(str, 10));
2
3const DateStringSchema = z.string().transform((str) => new Date(str));
4
5const UserInputSchema = z.object({
6  name: z.string().transform((str) => str.trim()),
7  age: z.string().transform((a) => parseInt(a, 10)),
8});

Benefits of Data Cleanup: Transformations handle the reality of user input — extra whitespace, inconsistent casing, and type mismatches from HTML forms. Your data is clean and consistent before it reaches your business logic or database.

Transform Use Cases:

Transforms are great for data cleaning and conversion. Common cases include:

  • String cleanup: Trimming whitespace, normalizing case
  • Type conversion: Converting strings to numbers or dates (especially useful for form data)
  • Data normalization: Converting different input formats to a standard format
  • Chaining with pipe(): Use pipe() to chain a transform with additional validation on the transformed value

The pipe() method helps when you need to transform data and then validate the result, as in the age example where we convert a string to a number and then check it meets a minimum age.

Union Types and Discriminated Unions

1// Simple union
2const StringOrNumber = z.union([z.string(), z.number()]);
3
4// Discriminated union
5const Shape = z.discriminatedUnion('type', [
6  z.object({ type: z.literal('circle'), radius: z.number() }),
7  z.object({ type: z.literal('square'), sideLength: z.number() }),
8]);

Understanding Union Types:

  • Simple unions: Use when a field accepts multiple types (like a string ID or a numeric ID)
  • Discriminated unions: For modeling data variants that share a common "discriminator" field. The discriminator tells Zod (and TypeScript) which schema to use.

Discriminated unions are especially useful for:

  • API responses with different shapes based on a type field
  • Form data that changes structure based on a selection
  • State management where different states have different data shapes

The discriminator field must use z.literal() to match values exactly.

Error Handling

Zod provides detailed error information:

1const schema = z.object({
2  email: z.email(),
3  age: z.number().min(18),
4});
5
6try {
7  schema.parse({ email: 'invalid', age: 16 });
8} catch (error) {
9  if (error instanceof z.ZodError) {
10      console.log(error.errors); // Array of validation issues
11      console.log(error.flatten()); // Flattened error structure
12  }
13}

Working with Zod Errors:

Zod provides rich error information for building user-friendly forms:

  • error.errors: An array of all validation issues, each with details like path, message, and code
  • error.flatten(): Reorganizes errors into a structure that's easier for form libraries to use
  • Error paths: Each error includes the exact path to the field that failed (e.g., ['user', 'email'] for nested objects)

This makes it easy to show specific messages next to the relevant form fields.

Advanced Features

Optional and Nullable Fields

1const UserSchema = z.object({
2  name: z.string(),
3  email: z.email().optional(), // Field can be undefined
4  phone: z.string().nullable(), // Field can be null
5  age: z.number().optional().nullable(), // Field can be undefined or null
6});

Optional vs Nullable - Important Distinction:

  • .optional(): The field can be undefined or missing entirely from the object
  • .nullable(): The field must be present but can have a null value
  • .optional().nullable(): The field can be undefined, missing, or null

This distinction matters for APIs and databases:

  • Use .optional() for fields that might not be provided (like optional form fields)
  • Use .nullable() for fields that are always present but might have no value (like a nullable database column)
  • Combine both when a field might be missing or explicitly set to null

Array Validation

1const NumberArraySchema = z.array(z.number());
2const StringArraySchema = z.string().array(); // Alternative syntax
3const TuppleSchema = z.tuple([z.string(), z.number()]); // Fixed-length array

Array Validation Approaches:

  • z.array(schema): Standard syntax for arrays of any length with elements of the given type
  • schema.array(): Alternative syntax some developers find more readable
  • z.tuple([...]): For fixed-length arrays where each position has a specific type (like coordinates [x, y] or database records)

Choose tuples when position matters and each element means something different. Use regular arrays for a collection of similar items.

Default Values with .catch()

1// .default() provides a value when the field is missing
2const SchemaWithDefault = z.object({
3  name: z.string(),
4  role: z.string().default('user'), // Used when field is missing
5});
6
7// .catch() provides a value when validation fails
8const SchemaWithCatch = z.object({
9  name: z.string(),
10  age: z.number().catch(0), // Used when validation fails (e.g., invalid number)
11  settings: z.object({
12      theme: z.enum(['light', 'dark']).catch('light'),
13      notifications: z.boolean().catch(true),
14  }),
15});
16
17// Example usage
18const result = SchemaWithCatch.parse({
19  name: 'John',
20  age: 'invalid-age', // This will fail validation and use catch value
21  settings: {
22      theme: 'invalid-theme', // This will fail and use 'light'
23      notifications: 'not-a-boolean', // This will fail and use true
24  },
25});
26
27// Result will be:
28// {
29//   name: 'John',
30//   age: 0,
31//   settings: {
32//     theme: 'light',
33//     notifications: true
34//   }
35// }

When to Use .catch():

  • Graceful degradation: When you want your application to continue working even with invalid data
  • User input sanitization: For handling malformed user inputs by falling back to safe defaults
  • API resilience: When consuming external APIs that might return unexpected data formats
  • Configuration parsing: For handling corrupted or partial config files

Key Differences:

  • .default(value): Used when the field is missing/undefined
  • .catch(value): Used when the field exists but validation fails
  • .optional().catch(value): Combines both - handles missing fields and validation failures

This is powerful for resilient apps that handle unexpected data gracefully instead of crashing.

Common Validation Patterns

1// Empty string handling
2const NonEmptyString = z.string().min(1, 'String cannot be empty');
3
4// Custom string formats (Zod v4 syntax)
5const URLSchema = z.url();
6const UUIDSchema = z.uuid();
7const EmailSchema = z.email();
8
9// Note: In Zod v3, these were z.string().url(), z.string().uuid(), etc.
10// In Zod v4, string format validators are now top-level functions
11
12// Numeric validations
13const PositiveNumber = z.number().positive();
14const IntegerSchema = z.number().int();
15const RangeSchema = z.number().min(0).max(100);
16
17// Date validation
18const FutureDateSchema = z.date().min(new Date());
19const PastDateSchema = z.date().max(new Date());

When to Use These Patterns:

  • Non-empty strings: For required form fields where empty strings should be invalid
  • String formats: Built-in validators for common patterns save you from writing custom regex
  • Numeric constraints: For form inputs, age restrictions, percentage values, etc.
  • Date constraints: For booking systems, age verification, or any time-sensitive data

These cover the most common validation scenarios in web apps, from registration forms to API endpoints.

Best Practices

  1. Reuse Common Schemas
1const BaseSchema = z.object({
2  id: z.uuid(),
3  createdAt: z.date(),
4  updatedAt: z.date(),
5});
6
7const UserSchema = BaseSchema.extend({
8  name: z.string(),
9  email: z.email(),
10});

Why This Works: Reusing common schemas keeps your app consistent and cuts duplication. To add a field like deletedAt to all entities, you update only the base schema.

  1. Custom Error Messages
1const FormSchema = z.object({
2  username: z
3      .string({
4          required_error: 'Username is required',
5          invalid_type_error: 'Username must be a string',
6      })
7      .min(3, 'Username must be at least 3 characters'),
8});

Why Custom Messages Matter: Default error messages are technical and not user-friendly. Custom messages give clear, actionable feedback. Different message types handle different cases:

  • required_error: When the field is missing entirely
  • invalid_type_error: When the field has the wrong type
  • Validation-specific messages: For length, format, or range constraints
  1. Use Transformations for Data Cleanup
1const UserInputSchema = z.object({
2  email: z
3      .string()
4      .email()
5      .transform((e) => e.toLowerCase()),
6  name: z.string().transform((n) => n.trim()),
7  age: z.string().transform((a) => parseInt(a, 10)),
8});

Benefits of Data Cleanup: Transformations handle the reality of user input — extra whitespace, inconsistent casing, and type mismatches from HTML forms. Your data is clean and consistent before it reaches your business logic or database.

Common Use Cases

  1. API Payload Validation
1const APIRequestSchema = z.object({
2  query: z.string().optional(),
3  page: z.number().int().positive().default(1),
4  limit: z.number().int().min(1).max(100).default(10),
5});

Real-world Application: This schema fits search or pagination endpoints. It checks query parameters, sets sensible defaults, and prevents abuse by limiting the maximum page size.

  1. Environment Variables Validation
1const EnvSchema = z.object({
2  DATABASE_URL: z.url(),
3  API_KEY: z.string().min(1),
4  PORT: z.string().transform((str) => parseInt(str, 10)),
5});

Real-world Application: Environment variables are strings by default, but your app needs them as proper types. This schema validates required config at startup and transforms values as needed, failing fast if the config is invalid.

  1. Configuration Validation
1const ConfigSchema = z.object({
2  server: z.object({
3      host: z.string(),
4      port: z.number(),
5  }),
6  database: z.object({
7      url: z.url(),
8      timeout: z.number().positive(),
9  }),
10  features: z.record(z.boolean()),
11});

Real-world Application: Configuration files can have complex nested structures. This schema checks your app config before starting, preventing runtime errors from typos or missing settings. The z.record(z.boolean()) type fits feature flags.

Conclusion

Zod is a handy tool for form validation, especially with TypeScript. With its schema declaration and validation, you can make sure form data matches the expected structure and follows your validation rules.

References

Related Articles

GET IN TOUCH

Let's work together

I build fast, accessible, and delightful digital experiences for the web. Whether you have a project in mind or just want to connect, I'd love to hear from you.

Get in touch

or reach out directly at hello@mohammadshehadeh.com