Data Validation with Zod
This is a typical form validation schema in Zod. Here's each field:
id: A simple string field with no additional constraintscustomerId: A string field with a custom error message when the wrong type is providedamount: Usesz.coerce.number()to automatically convert string inputs (like from HTML forms) to numbers, then validates it's greater than 0status: An enum that only accepts specific values ('pending' or 'paid'), perfect for dropdown selectionsdate: 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": truemust be enabled in yourtsconfig.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
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 — eithersuccess: truewith the validated data, orsuccess: falsewith 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:
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:
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:
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:
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
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
typefield - 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:
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 likepath,message, andcodeerror.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
Optional vs Nullable - Important Distinction:
.optional(): The field can beundefinedor missing entirely from the object.nullable(): The field must be present but can have anullvalue.optional().nullable(): The field can beundefined, missing, ornull
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
Array Validation Approaches:
z.array(schema): Standard syntax for arrays of any length with elements of the given typeschema.array(): Alternative syntax some developers find more readablez.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()
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
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
- Reuse Common Schemas
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.
- Custom Error Messages
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 entirelyinvalid_type_error: When the field has the wrong type- Validation-specific messages: For length, format, or range constraints
- Use Transformations for Data Cleanup
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
- API Payload Validation
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.
- Environment Variables Validation
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.
- Configuration Validation
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.