Skip to Content
Course content

72: Runtime Validation with Zod

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a pull request for a teammate who had built a sleek integration with a third-party shipping API. The code looked pristine. He'd defined a comprehensive ShippingManifest interface, and every function used it perfectly. I remember thinking, "This is exactly why we use TypeScript."

Two days after the deploy, we got a critical alert. The API had started returning null for the tracking_number field on certain international orders—something the API documentation promised would always be a string. Because the TypeScript interface told the compiler the field was a string, the teammate hadn't written any manual checks for it. The app tried to call .toLowerCase() on that null value, and the entire checkout page crashed for about 5% of our users.

This is the "TypeScript Lie." We often forget that TypeScript is a compile-time tool. Once your code is transpiled to JavaScript and running in a browser or on a server, those interfaces vanish. They provide zero protection against malformed JSON, unexpected API changes, or user input. That's where Zod comes in.

Turning Schemas Into Type Guards

Zod allows you to define a schema—a runtime representation of your data—that can actually validate the data as it enters your system. Instead of just telling TypeScript "I expect this to be a User," you're telling the runtime "Verify that this is a User, and if it isn't, throw an error immediately."

Let's look at how we would have handled that shipping manifest issue. Instead of just an interface, we create a Zod schema:

import { z } from "zod";

const ShippingManifestSchema = z.object({
  trackingNumber: z.string().min(1, "Tracking number cannot be empty"),
  weight: z.number().positive(),
  carrier: z.enum(["FedEx", "UPS", "DHL"]),
  estimatedDelivery: z.string().datetime(),
});

Now, when the data arrives from the API, you don't just cast it using as ShippingManifest. Instead, you use the .parse() method. If the data doesn't match the schema, Zod throws a ZodError, allowing you to catch the failure at the boundary of your application rather than letting a null value seep deep into your business logic where it's harder to trace.

The Magic of Type Inference

One of the biggest headaches with runtime validation in the past was "Double Declaration." You'd have to write a validation function and then manually write a TypeScript interface that mirrored that validation. If you added a field to one, you'd inevitably forget the other, leading to the very bugs we're trying to avoid.

Zod solves this with z.infer. You define the schema once, and Zod extracts the TypeScript type from it automatically.

// No need to manually write 'interface ShippingManifest { ... }'
type ShippingManifest = z.infer<typeof ShippingManifestSchema>;

async function fetchManifest(id: string): Promise<ShippingManifest> {
  const response = await fetch(`/api/shipping/${id}`);
  const data = await response.json();
  
  // This validates the data AND returns it typed as ShippingManifest
  return ShippingManifestSchema.parse(data);
}

I personally prefer this approach because the schema becomes the "Single Source of Truth." If I change weight from a number to a string in the schema, TypeScript will immediately flag every single place in my codebase where I treated that weight as a number.

Handling Validation Failures Gracefully

While .parse() is great for when you're certain the data should be correct, it's a bit aggressive because it throws exceptions. In many cases—like validating a user-submitted form—you don't want your app to crash; you want to return a helpful error message.

For those scenarios, use .safeParse(). It returns an object indicating whether the validation succeeded or failed, without throwing an error.

const result = ShippingManifestSchema.safeParse(apiResponse);

if (!result.success) {
  // result.error contains a detailed array of exactly which fields failed and why
  console.error("Invalid API response:", result.error.format());
  return handleApiError();
}

// Now 'result.data' is fully typed and guaranteed to be valid
console.log(result.data.trackingNumber);



📋 Practical Task

Exercise: Building a Robust Product Inventory Validator

You are building an inventory management system. You receive product updates from a legacy CSV-to-JSON converter that is notoriously unreliable. Your task is to create a validation layer using Zod to ensure no "garbage" data enters your state.

Requirements:

  • Create a Zod schema named ProductSchema that validates:
    • id: A string that must be a UUID (use z.string().uuid()).
    • name: A string with a minimum length of 3 characters.
    • price: A number that must be greater than 0.
    • category: A string that must be one of the following: "Electronics", "Clothing", or "Home".
    • tags: An array of strings, where each string is at least 2 characters long.
  • Use z.infer to create a Product type from the schema.
  • Write a function called processProductUpdate that takes an unknown value, uses safeParse to validate it against the ProductSchema, and:
    • Returns the validated product if successful.
    • Logs a specific error message and returns null if validation fails.

Test your implementation with these two cases:

const validProduct = {
  id: "550e8400-e29b-41d4-a716-446655440000",
  name: "Mechanical Keyboard",
  price: 120.00,
  category: "Electronics",
  tags: ["gaming", "peripherals"]
};

const invalidProduct = {
  id: "123-bad-id",
  name: "Hi",
  price: -10,
  category: "Food",
  tags: ["a"]
};
Rating
0 0

There are no comments for now.

to be the first to leave a comment.