Mastering Zod Validation

Mastering Data Validation and Error Handling in TypeScript with Zod

Introduction

In TypeScript applications, static typing offers safety during development, ensuring that variables and functions respect well-defined contracts. However, this guarantee does not always extend to external data, where malformed or unexpected information can compromise the system’s robustness. Besides not following the pattern expected by the business rule.

This blog post explores how Zod, a TypeScript-first schema declaration and validation library, solves this critical challenge. We will explore advanced use cases, such as discriminated unions and data transformation during validation. By the end, you will have a clear understanding of how to incorporate Zod into your projects to ensure robust validations.

Runtime Data vs. Static Types

TypeScript’s static type checking only applies at compile time. It cannot protect against data that doesn’t match your expectations at runtime. Consider a common scenario in a REST API where a Product interface is defined:

interface Product {
  id: number;
  name: string;
  price: number;
  category: "electronics" | "books" | "clothing";
}

const requestBody = {
  id: -1,    // Negative ID (invalid)
  name: abc,    // Name is not a string (would cause a runtime error)
  price: "99.99",   // Price as string (should be number)
  category: "food"  // Category not allowed
};

function createProduct(product: Product) {
  console.log("Product created:", product);
}

createProduct(requestBody);

In this case, TypeScript does not emit errors during compilation. The type system assumes requestBody is correct because its structure looks similar to the Product interface. However, the data is clearly invalid, and these errors will only surface at runtime, potentially causing crashes or logical bugs deep within the system.

Validation with Zod

To mitigate these risks, we need a way to validate the structure and content of data at runtime, that’s when Zod emerges as a robust solution, offering validation of the data format (e.g., price must be a number greater than or equal to zero), restriction of values to a specific domain (e.g., category as an enum), and the guarantee of type-safety from input to the database. Now consider the corrected code of the API shown before:

import { z } from "zod";
import express from "express";

const app = express();
app.use(express.json());

// Defining the Validation Schema
const ProductSchema = z.object({
 id: z.number().int().positive(), // ID must be a positive integer
 name: z.string().min(5, 'Username must be at least 5 characters'),
 price: z.number().min(0, 'Price must be a positive number or zero'),    // Price cannot be negative
 category: z.enum(["electronics", "books", "clothing"], 'Must respect the available category values') // Allowed categories
});

// Route with Validation
app.post("/products", (req, res) => {
 const validation = ProductSchema.safeParse(req.body);

if (!validation.success) {
 return res.status(400).json({
 error: "Invalid data",
 details: validation.error.issues, 
 });
 }

const productData = validation.data; 

console.log("Valid product:", productData);
res.status(201).send("Product created!");
});

Applications and Advanced Use Cases

While the Zod documentation covers basic validation cases, real applications often demand more sophisticated patterns. Next, we will explore complex scenarios that go beyond the trivial:

1. Validation of Nested Object Hierarchies

In real systems, data rarely comes in flat structures. Consider a customer registration with multiple addresses:

const AddressSchema = z.object({
  street: z.string(),
  number: z.number().int().positive(),
  zipCode: z.string().regex(/^\d{5}-\d{3}$/) // Validates format XXXXX-XXX
});

const CustomerSchema = z.object({
  name: z.string().min(3), // The name variable must have a minimum of 3 characters
  addresses: z.array(AddressSchema).nonempty() // Validates the minimum quantity of having at least one address
});

This way, we validate nested objects as a whole, instead of performing individual validation of the elements, we ensure that all of them follow the same rules and structure. If one of them fails, the entire customer is rejected. The customer schema includes the name and a list of addresses, an object with 3 elements ensured by the address schema. Usage example:

// Valid
const validCustomer = CustomerSchema.parse({
    name: "João Silva",
    addresses: [{ street: "Rua A", number: 123, zipCode: "12345-678" }]
});

// Invalid (malformed zipCode)
CustomerSchema.parse({
    name: "Maria",
    addresses: [{ street: "Rua B", number: 456, zipCode: "12345678" }]
});

2. Conditional Data Transformation

To handle different schemas within the same object, we can apply the use of discriminatedUnion. It is a conditional structure that allows choosing a schema based on an associated variable. For example, in an e-commerce system, various payment methods can be used, each with a different method:

const PaymentSchema = z.discriminatedUnion("method", [
    z.object({
    method: z.literal("card"), // Discriminant field
    number: z.string().length(16), // Specific rules for card
    cvv: z.string().length(3) // Restricted quantity for cvv
    }),
    z.object({
    method: z.literal("pix"), // Other discriminant
    key: z.string().email() // PIX key that validates email input
    })
]);

This way the "method" field defines which schema will be applied among the available options: card or pix. Operation:

// Valid (card)
PaymentSchema.parse({
    method: "card",
    number: "1234567812345678",
    cvv: "123"
});

// Valid (PIX)
PaymentSchema.parse({
    method: "pix",
    key: "joao@email.com"
});

// Invalid (unknown method)
PaymentSchema.parse({
    method: "boleto", 
    key: "123"
});

3. Mastering .transform() and .refine()

The .transform() and .refine() methods are crucial for the need to standardize received values and the obligation to impose specific validation rules that go beyond basic type checking. Let’s examine a concrete case of a registration form, where we need to ensure that the data is not only correct but also consistent:

const UserRegistrationSchema = z.object({
    username: z.string()
    .min(5, 'Username must be at least 5 characters long')
    .transform(name => name.trim().toLowerCase()),
    email: z.string()
    .email('Invalid email format')
    .transform(email => email.toLowerCase()),
    password: z.string()
    .min(8, 'Password must contain at least 8 characters'),
    zipCode: z.string()
    .refine(zipCode => /^\d{5}-\d{3}$/.test(zipCode), {
    message: 'Invalid Zip Code format (XXXXX-XXX)'
    })
});

In this example, we apply transformations to normalize the data (converting to lowercase and removing extra spaces) while validating specific formats like the Zip Code. The .transform() acts by modifying the value after basic validation, while .refine() allows creating custom rules without altering the original data.

Another use case example can be seen in a reservation system where we need to ensure that the check-out date is after the check-in date:

const HotelReservationSchema = z.object({
    checkIn: z.string().transform(str => new Date(str)),
    checkOut: z.string().transform(str => new Date(str))
}).refine(
    data => data.checkOut > data.checkIn, {
        message: 'Check-out date must be after check-in',
        path: ['checkOut']
    }
);

This way, the checkIn and checkOut dates have the string str transformed into a Date value, and then a rule is described with .refine() to perform date validation. If checkOut is greater than checkIn, then an error is thrown with the described message and indicating which schema field the error message should be associated with, in this case ‘checkOut’.

In short, the choice between .transform() and .refine() follows a clear logic: use transformations when you need to modify the value to standardize or convert it, and refinements when you need to validate without changing the original data.

4. Simplifying Numeric Validations with z.coerce()

A common challenge when validating data in APIs is dealing with parameters that arrive as strings but need to be treated as numbers, such as pagination (page, pageSize) or IDs. Traditionally, this required multiple validation steps, but Zod offers an elegant solution with z.coerce(), which automates the conversion and maintains TypeScript’s type safety.

Imagine an API endpoint that receives pagination parameters via query string:

/users?page=1&pageSize=10

Since req.query always returns strings, we need to validate if the value is a numeric string (e.g., "1" instead of "abc"), then convert it to a number, and finally ensure it is a positive integer.

Before coerce, the solution involved combining regex, refine, and transform, for example:

const FindPagesInput = z.object({
    page: z.string()
        .regex(/^\d+$/, "Must contain only digits")
        .refine((val) => parseInt(val) > 0, "Must be positive")
        .transform((val) => parseInt(val))
        .default("1"),
    pageSize: z.string()
        .regex(/^\d+$/, "Must contain only digits")
        .refine((val) => parseInt(val) > 0, "Must be positive")
        .transform((val) => parseInt(val))
        .default("1"),
});

This code works, but it is repetitive and follows a more complex structure. As a solution with z.coerce(), it automatically converts strings to numbers during validation, simplifying the entire schema:

const FindPagesInput = z.object({
    page: z.coerce.number() // Converts to number
    .int()    // Ensures it is an integer
    .positive()    // Validates if > 0
    .default(1),    // Default value as number
    pageSize: z.coerce.number()
    .int()
    .positive()
    .default(10)
});

Therefore, if the input is a numeric string ("123"), coerce converts it to 123. But if it is a number (123), it keeps the value. Now if the conversion fails with, for example: "abc", Zod returns an error even before the int and positive validations.

z.coerce() is a powerful ally for reducing repetitive code in numeric validations.
It combines automatic conversion with the robustness of Zod, maintaining TypeScript’s type safety. A good use is described for "dirty" data that needs to be standardized (like query params) and strict schemas (with refine/transform) for complex business rules.

Besides numeric validation cases, coerce can also be used for checking strings, dates, and booleans, for example. Having a range of possibilities for using this tool. Operation:

const UserFormSchema = z.object({
// Converts string to number automatically
age: z.coerce.number().int().positive().min(18),

// Converts string to boolean
isSubscribed: z.coerce.boolean(),
// Keeps as string, but with min validation
name: z.coerce.string().min(2),

// Converts string to date
birthDate: z.coerce.date()
});

// Data received from the form
const formData = {
    age: "25", // → 25 (number)
    isSubscribed: "on", // → true (boolean)
    name: "João", // → "João" (string)
    birthDate: "1990-01-01" // → Date object
};

const validatedData = UserFormSchema.safeParse(formData);

5. Mastering Complex Validations with z.pipe()

When we seek to perform multiple validations on a piece of data, we use the .pipe() element. It is capable of creating a chain of data transformations and validations sequentially, making the code leaner and less confusing. Besides allowing reusable intermediate schemas. Usage example with reusable basic schemas:

// Atomic schemas
const NumericString = z.string().regex(/^\d+$/, "Must contain only numbers");
const PositiveInt = z.coerce.number().int().positive();

// Schemas being reused
const IdSchema = NumericString.pipe(PositiveInt);

// Usage:
const UserSchema = z.object({
    id: IdSchema,
    age: PositiveInt // Reusing the same schema
});

Furthermore, we can apply the extension of an existing schema. Example with evolution of product validation:

// Base schema
const BaseProductSchema = z.object({
    name: z.string().min(3),
    price: z.coerce.number().positive()
});

// Extended schema with new rules
const CompleteProductSchema = BaseProductSchema.pipe(
    z.object({
    price: z.number().refine(val => val < 1000, "Maximum price is R$1000"),
    category: z.enum(["electronics", "clothing"])
    })
);

6. Selection of Specific Fields with z.pick()

In a simplified way, using this method we can create a new schema containing only some fields from an existing schema, simplifying the solution. Usage example:

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email(),
    age: z.number().optional()
});

// Selects only name and email to compose the schema
const UserBasicInfo = UserSchema.pick({ name: true, email: true });

// Usage: id and age are ignored
UserBasicInfo.parse({
    name: "João Silva",
    email: "joao@exemplo.com"
 });

This way we can create partial views of data for different contexts and validate data subsets in specific endpoints.

Error Handling

Data validation only achieves its purpose when combined with a consistent error-handling mechanism. In this regard, Zod provides a robust solution through the ZodError class, responsible for capturing and structuring all schema violations.

Unlike approaches that stop at the first failure or return generic messages, Zod consolidates all detected inconsistencies into a single report. This report, represented by the ZodError object, provides detailed information about the invalid fields and the causes of the errors, enabling both precise analysis of issues and customization of messages according to the application’s needs.

Anatomy of a ZodError

The issues property of a ZodError is an array in which each element describes a specific schema violation, containing:

  • code: the type of error (e.g., invalid_type, invalid_string, too_small, custom).

  • path: an array indicating the path to the invalid field (e.g., [‘addresses’, 0, ‘zipCode’]).

  • message: a detailed description of the encountered problem.

  • expected/received: expected types versus the actual values received.

This structured format allows developers to quickly identify not only which fields failed, but also why the validation was unsuccessful.

Practice: Formatting Errors

Returning the entire validation.error.issues array directly can result in overly verbose responses for the client.

A recommended practice is to format these errors so they are clearer and easier to consume:

import { z } from "zod";
import express from "express";

const app = express();
app.use(express.json());

const ProductSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(5, "Username must be at least 5 characters"),
  price: z.number().min(0, "Price must be a positive number or zero"),
  category: z.enum(["electronics", "books", "clothing"], "Must respect the available category values")
});

app.post("/products", (req, res) => {
  const validation = ProductSchema.safeParse(req.body);

  if (!validation.success) {
    const formattedErrors = validation.error.issues.map((issue) => ({
      field: issue.path.join("."), // Example: "addresses.0.zipCode"
      message: issue.message,
    }));

    return res.status(400).json({
      error: "Invalid input data",
      details: formattedErrors,
    });
  }
});

A possible API response for invalid input could be:

{
  "error": "Invalid input data",
  "details": [
    {
      "field": "id",
      "message": "Number must be greater than 0"
    },
    {
      "field": "category",
      "message": "Invalid enum value. Expected 'electronics' | 'books' | 'clothing', received 'food'"
    }
  ]
}

Customizing Messages

Zod allows message customization at all validation levels, ensuring clarity and consistency in error communication:

const LoginSchema = z.object({
  email: z.string().email("Please provide a valid email address"), 
  password: z.string().min(8, "Password must be at least 8 characters long") 
});

Zod’s error handling goes far beyond simply throwing exceptions.
It offers a structured and customizable mechanism for dealing with validation failures, enabling the development of reliable applications with clear and consistent error messages for the end user.

Conclusion

In summary, we explored how Zod establishes itself as a crucial tool for TypeScript developers seeking to ensure data integrity in their applications. From basic validations to complex scenarios of transformation and schema composition, Zod offers a powerful set of functionalities that go far beyond simple type checking. As main highlighted benefits:

  • Runtime Safety: Complements TypeScript’s static typing, validating external data (APIs, forms, databases) before they cause system failures.
  • Productivity: Declarative schemas reduce repetitive code and facilitate maintenance (e.g., z.coerce.number() vs manual validations).
  • Flexibility: Features like .pipe(), .transform(), and .refine() allow creating complex business rules with clarity.
  • Developer Experience: Descriptive error messages and seamless integration with TypeScript (type inference).

References

https://zod.dev
https://medium.com/@weidagang/zod-schema-validation-made-easy-195f86d82d44
https://blog.logrocket.com/schema-validation-typescript-zod/#objects-zod
https://www.rocketseat.com.br/blog/artigos/post/discriminated-unions-types-no-typescript
https://basicutils.com/learn/zod/zod-transform-data-transformation
https://basicutils.com/learn/zod/zod-refine-custom-validation

We want to work with you. Check out our Services page!