Skip to content
ZeroServer.tools
All guides

Runtime Type Validation with Zod: A Practical Guide

June 10, 2026 · 5 min read

Runtime Type Validation with Zod: A Practical Guide

TypeScript gives you compile-time safety. The moment your application talks to the outside world — an API response, a form submission, a database query, an environment variable — that safety evaporates. The JSON that comes back at runtime doesn't know about your TypeScript interfaces. Zod bridges that gap: it validates data at runtime and infers TypeScript types from the same schema, so you write the shape once and get safety both at build time and at runtime.

The Problem TypeScript Alone Can't Solve

Consider this pattern, which appears in nearly every TypeScript codebase:

interface User {
  id: number;
  name: string;
  email: string;
}

const response = await fetch("/api/users/1");
const user: User = await response.json();   // ← this is a lie
console.log(user.name.toUpperCase());       // TypeError if name is null

The as User cast (or the type annotation above) tells TypeScript "trust me, this is a User." TypeScript believes you, and your editor shows no errors. But if the API returns { id: 1, name: null, email: "..." } — perhaps the endpoint changed, or the field is optional in the database — you get a runtime TypeError with no warning.

Zod fixes this by actually checking the shape of the data at runtime:

import { z } from "zod";

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

type User = z.infer<typeof UserSchema>;   // derived, not duplicated

const raw = await response.json();
const user = UserSchema.parse(raw);       // throws if invalid
console.log(user.name.toUpperCase());     // safe — name is guaranteed string

If name is null, parse() throws a ZodError with a clear message (Expected string, received null) rather than a cryptic downstream TypeError.

Zod Schema Primitives

z.string()          // string
z.number()          // number (float or int)
z.number().int()    // integer only
z.boolean()         // boolean
z.null()            // null
z.undefined()       // undefined
z.literal("admin")  // exact value
z.unknown()         // any value, still typed as unknown
z.any()             // opt out of validation entirely

Composing Schemas

// Arrays
z.array(z.string())              // string[]
z.string().array()               // same, chained

// Objects
z.object({
  id: z.number(),
  tags: z.array(z.string()),
})

// Optional and nullable fields
z.string().optional()            // string | undefined
z.string().nullable()            // string | null
z.string().nullish()             // string | null | undefined

// Unions
z.union([z.string(), z.number()]) // string | number
z.string().or(z.number())         // same, chained

// Enums
z.enum(["admin", "user", "guest"])

// Tuples (fixed-length arrays with typed positions)
z.tuple([z.string(), z.number()])

parse vs safeParse

parse() throws on failure. safeParse() returns a result object — useful when you want to handle errors gracefully without try/catch:

const result = UserSchema.safeParse(rawData);

if (!result.success) {
  console.error(result.error.issues);
  // [{ path: ["name"], message: "Expected string, received null" }]
  return;
}

const user = result.data;  // fully typed, validated User

Use safeParse() at system boundaries. Use parse() when an invalid value is a programming error that should crash early.

Validating API Responses

const PostSchema = z.object({
  id: z.number(),
  title: z.string().max(200),
  body: z.string(),
  userId: z.number(),
});

const PostListSchema = z.array(PostSchema);

async function getPosts(): Promise<z.infer<typeof PostListSchema>> {
  const res = await fetch("https://api.example.com/posts");
  const json = await res.json();
  return PostListSchema.parse(json);   // validated and typed
}

If the API changes its schema — adds a required field, changes a type — your Zod schema catches it at the boundary and you get a clear error message rather than a downstream crash.

Validating Environment Variables

This is one of the highest-value uses of Zod: validating process.env at startup so misconfigured deployments fail immediately with clear messages rather than silently using undefined values.

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.string().regex(/^\d+$/).transform(Number),
  NODE_ENV: z.enum(["development", "production", "test"]),
  API_KEY: z.string().min(32),
});

export const env = EnvSchema.parse(process.env);
// Now env.PORT is typed as number, env.NODE_ENV is the union type

If DATABASE_URL is missing, the app crashes at startup with Required rather than failing mysteriously during the first database query.

Transformations and Refinements

Zod can transform values as it validates them:

// Parse a string date into a Date object
z.string().transform(s => new Date(s))

// Coerce a string to a number
z.coerce.number()   // uses Number() coercion

// Custom refinements (additional validation logic)
z.string().refine(
  s => s.includes("@"),
  { message: "Must contain @" }
)

z.number().min(0).max(100)    // range validation
z.string().min(1).max(255)    // length validation
z.string().url()              // URL format
z.string().email()            // email format
z.string().uuid()             // UUID format

Generating a Zod Schema from Existing JSON

If you have an existing API response or JSON sample, you don't need to write the schema from scratch. Paste the JSON into the JSON to Zod Schema generator to get a starting schema automatically:

{
  "id": 1,
  "name": "Alice",
  "active": true,
  "score": 9.5,
  "tags": ["admin", "user"]
}

Generates:

import { z } from "zod";

const schema = z.object({
  "id": z.number().int(),
  "name": z.string(),
  "active": z.boolean(),
  "score": z.number(),
  "tags": z.array(z.string()),
});

type Schema = z.infer<typeof schema>;
export { schema, type Schema };

The generator infers types from the example values. You'll want to review the output — add .optional() to optional fields, add .email() or .url() where appropriate, and narrow literal types if needed — but it gives you a solid starting point from any JSON sample.

Zod vs Other Validation Libraries

Library Approach TypeScript integration
Zod Schema-first, type inference from schema First-class
Yup Schema-first, JS-original Added later, less precise
Joi Schema-first, works without TypeScript TypeScript via types package
io-ts Type-first (build types from codecs) First-class but verbose
Valibot Schema-first, modular (smaller bundle) First-class

Zod has become the de facto standard in the TypeScript ecosystem, particularly with tRPC and Next.js. Its DX (developer experience) and TypeScript inference quality are the main reasons for its adoption.

Tools

Generate a Zod schema from any JSON example with the JSON to Zod Schema tool. For formatting and validating the JSON itself, use the JSON Formatter. To generate TypeScript interfaces from JSON (compile-time only, no runtime validation), use JSON to TypeScript. For JSON Schema (a different standard), use the JSON Schema Generator.