Data Validation with Zod
This example demonstrates a typical form validation schema in Zod. Let's break down 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 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
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 eithersuccess: true
and the validated data, orsuccess: 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:
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
:
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:
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:
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
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:
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 likepath
,message
, andcode
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
Optional vs Nullable - Important Distinction:
.optional()
: The field can beundefined
or missing entirely from the object.nullable()
: The field must be present but can have anull
value.optional().nullable()
: The field can beundefined
, missing, ornull
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
Array Validation Approaches:
z.array(schema)
: Standard syntax for arrays of any length containing elements of the specified typeschema.array()
: Alternative syntax that 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 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()
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
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
- Reuse Common Schemas
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.
- Custom Error Messages
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 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. This approach ensures 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 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.
- Environment Variables Validation
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.
- Configuration Validation
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
Let's work together
I build exceptional and accessible digital experiences for the web
WRITE AN EMAILor reach out directly at hello@mohammadshehadeh.com