MSH Logo

Data Validation with Zod

Published on
Last updated on
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 example demonstrates a typical form validation schema in Zod. Let's break down 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 beauty of this approach is that you get both runtime validation and TypeScript types from a single schema definition.

What is Zod?

According to the official website, Zod is a TypeScript-first schema validation library with static type inference. It helps ensure your data matches the expected shape and types at runtime, while providing excellent TypeScript integration.

The goal is to eliminate duplication type declarations. With Zod, you declare a validator once and Zod will automatically infer the static TypeScript type. This means you don't need to maintain separate type definitions and runtime validations.

Key Features:

  • Zero dependencies
  • Works in Node.js and all modern browsers
  • Tiny: 8kb minified + zipped
  • 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

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.string().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 an error immediately when validation fails. Use this when you want exceptions to bubble up and stop execution.
  • safeParse(): Returns a result object with either success: true and the validated data, or success: false with error details. This is safer for user-facing applications where you want to handle errors gracefully.

The email validation automatically checks for proper email format, and all types are enforced at runtime while providing TypeScript intellisense.

Type Inference

Zod automatically infers TypeScript types from your schemas:

1import { z } from 'zod'; 2 3const UserSchema = z.object({ 4 id: z.string().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 your validation schema once, and TypeScript types are automatically generated. Notice how the optional .optional() modifier translates to age?: number in the TypeScript type. This eliminates the need to maintain separate type definitions and validation schemas, reducing code duplication and potential 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 when you need one custom validation rule. The validation function should return a boolean.
  • superRefine(): More powerful for complex validations involving multiple fields, custom error paths, or when you need fine-grained control over error messages and locations.

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

Schema Composition and Extension

Zod makes it easy to compose and extend schemas:

1// Base schema 2const BaseSchema = z.object({ 3 id: z.string().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.string().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. Perfect for creating specialized versions of base schemas.
  • partial(): Makes all top-level fields optional. Useful for update operations where you might only want to change some fields.
  • deepPartial(): Makes all fields optional recursively, including nested object fields. Ideal for partial updates of complex nested data.

This composition approach promotes code reuse and maintains consistency across related schemas. For example, you might have a base EntitySchema with common fields like id, createdAt, updatedAt that you extend for specific entities like User, Product, etc.

Transform and Preprocess

Zod allows you to 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. This approach ensures your data is clean and consistent before it reaches your business logic or database.

Transform Use Cases:

Transforms are powerful for data cleaning and conversion. Common scenarios 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 is particularly useful when you need to transform data and then validate the transformed result, as shown in the age example where we convert a string to a number and then ensure it meets minimum age requirements.

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 can accept multiple types (like a field that accepts either a string ID or numeric ID)
  • Discriminated unions: Perfect for modeling different variants of data that share a common "discriminator" field. The discriminator helps Zod (and TypeScript) determine 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 ensure exact matching of the discriminator values.

Error Handling

Zod provides detailed error information:

1const schema = z.object({ 2 email: z.string().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 that's perfect 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 more convenient structure for form libraries
  • Error paths: Each error includes the exact path to the field that failed (e.g., ['user', 'email'] for nested objects)

This detailed error information makes it easy to show specific error messages next to the relevant form fields, providing a great user experience.

Advanced Features

Optional and Nullable Fields

1const UserSchema = z.object({ 2 name: z.string(), 3 email: z.string().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 is crucial 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 intentionally 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 containing elements of the specified type
  • schema.array(): Alternative syntax that 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 the position matters and each element has a different meaning. Use regular arrays when you have 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 particularly powerful for building resilient applications that can handle unexpected data gracefully rather than crashing.

Common Validation Patterns

1// Empty string handling 2const NonEmptyString = z.string().min(1, 'String cannot be empty'); 3 4// Custom string formats 5const URLSchema = z.string().url(); 6const UUIDSchema = z.string().uuid(); 7const EmailSchema = z.string().email(); 8 9// Numeric validations 10const PositiveNumber = z.number().positive(); 11const IntegerSchema = z.number().int(); 12const RangeSchema = z.number().min(0).max(100); 13 14// Date validation 15const FutureDateSchema = z.date().min(new Date()); 16const PastDateSchema = z.date().max(new Date());

When to Use These Patterns:

  • Non-empty strings: Essential 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: Perfect for form inputs, age restrictions, percentage values, etc.
  • Date constraints: Useful for booking systems, age verification, or any time-sensitive data

These patterns handle the most common validation scenarios you'll encounter in web applications, from user registration forms to API endpoints.

Best Practices

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

Why This Works: Reusing common schemas ensures consistency across your application and reduces duplication. When you need to add a field like deletedAt to all entities, you only update 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 improve the user experience by providing clear, actionable feedback. Different message types handle different scenarios:

  • 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. This approach ensures 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 is perfect for search or pagination endpoints. It ensures query parameters are valid, provides sensible defaults, and prevents abuse by limiting the maximum page size.

  1. Environment Variables Validation
1const EnvSchema = z.object({ 2 DATABASE_URL: z.string().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 application needs them as proper types. This schema validates required config at startup and transforms values as needed, failing fast if configuration 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.string().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 ensures your app config is valid before starting, preventing runtime errors from typos or missing required settings. The z.record(z.boolean()) type is perfect for feature flags.

Conclusion

Zod has become a handy tool for developers dealing with form validation, especially when combined with TypeScript. Using Zod's schema declaration and validation features, developers can ensure that the data entered into forms aligns with the specified structure and adheres to predefined validation rules.

References

Buy Me a Coffee at ko-fi.com
GET IN TOUCH

Let's work together

I build exceptional and accessible digital experiences for the web

WRITE AN EMAIL

or reach out directly at hello@mohammadshehadeh.com