MervCodes

Tech Reviews From A Programmer

Zod Validation Guide: Runtime Type Safety in TypeScript

1 min read

TypeScript gives you compile-time type safety, but it disappears the moment your code runs. The API response you JSON.parse, the form data a user submits, the environment variables you read at startup — TypeScript trusts them all blindly. A field typed as string can hold undefined at runtime, and your only warning is a crash three functions later. Zod closes this gap. It lets you define a schema once and get both runtime validation and a static TypeScript type from the same source of truth.

This guide walks through the practical patterns you'll actually use, from your first schema to composing validation across an entire codebase.

Why Runtime Validation Matters

Consider a typical fetch call:

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

const res = await fetch("/api/user");
const user: User = await res.json(); // a lie

That type annotation is a promise you made to the compiler, not a fact. If the API returns { id: "42", email: null }, TypeScript never notices. The bug surfaces later, far from its cause. Zod turns that assumption into a checked boundary — data is validated at the edge of your system, so everything inside can trust its types.

Getting Started

Install Zod and define your first schema:

npm install zod
import { z } from "zod";

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

type User = z.infer<typeof UserSchema>;

The z.infer helper is the key idea. You write the schema once, and TypeScript derives the type automatically. There's no duplicate interface to keep in sync — the schema is the type. Change the schema, and every consumer updates at compile time.

Parsing and Safe Parsing

Zod gives you two ways to validate. parse throws on invalid data:

const user = UserSchema.parse(await res.json());
// user is fully typed as User, guaranteed valid

safeParse returns a result object instead of throwing, which is better when you expect failures and want to handle them gracefully:

const result = UserSchema.safeParse(await res.json());

if (!result.success) {
  console.error(result.error.issues);
  return;
}

const user = result.data; // typed and validated

Use parse at trust boundaries where invalid data is a genuine bug (like environment config). Use safeParse for anything driven by user input or external services, where failure is an expected path you must handle.

Common Schema Types

Zod covers the full range of TypeScript's type system. Here are the building blocks you'll reach for constantly:

z.string().min(3).max(100);
z.number().int().positive();
z.boolean();
z.date();
z.string().datetime(); // ISO 8601 string
z.enum(["draft", "published", "archived"]);
z.literal("admin");
z.array(z.string());
z.record(z.string(), z.number()); // { [key: string]: number }
z.tuple([z.string(), z.number()]);

Optional and nullable fields are explicit, which forces you to think about absence:

const ProfileSchema = z.object({
  bio: z.string().optional(),        // string | undefined
  avatar: z.string().nullable(),     // string | null
  website: z.string().url().nullish(), // string | null | undefined
});

Transformations and Defaults

Validation is often the right place to normalize data. Zod can coerce, default, and transform as it validates:

const ConfigSchema = z.object({
  port: z.coerce.number().default(3000),
  host: z.string().default("localhost"),
  tags: z.string().transform((s) => s.split(",")),
});

z.coerce.number() is especially useful for environment variables and query strings, which always arrive as strings. transform lets the output type differ from the input — here a comma-separated string becomes a string[]. Note that z.infer gives you the output type; use z.input when you need the pre-transform shape.

Refinements for Custom Logic

When built-in validators aren't enough, refine and superRefine let you express arbitrary rules:

const SignupSchema = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "Passwords do not match",
    path: ["confirm"],
  });

The path property attaches the error to a specific field, which matters for form libraries that render errors inline. For multiple custom checks with fine-grained control, superRefine gives you direct access to the issue-reporting context.

Composing and Reusing Schemas

Real applications share structure. Zod schemas compose like functions:

const BaseEntity = z.object({
  id: z.string().uuid(),
  createdAt: z.date(),
});

const Post = BaseEntity.extend({
  title: z.string(),
  body: z.string(),
});

const PostPreview = Post.pick({ id: true, title: true });
const PostDraft = Post.omit({ id: true, createdAt: true }).partial();

extend, pick, omit, partial, and merge mirror TypeScript's own utility types but operate on runtime schemas. This keeps your validation DRY and your types consistent. For union types, use z.union or the more efficient z.discriminatedUnion when objects share a literal tag field:

const Event = z.discriminatedUnion("type", [
  z.object({ type: z.literal("click"), x: z.number(), y: z.number() }),
  z.object({ type: z.literal("key"), key: z.string() }),
]);

Discriminated unions parse faster and produce clearer errors, because Zod checks the tag first and only validates the matching branch.

Practical Patterns

Validate environment variables at startup. Fail fast rather than discovering a missing secret in production:

const env = z
  .object({
    DATABASE_URL: z.string().url(),
    PORT: z.coerce.number().default(8080),
    NODE_ENV: z.enum(["development", "production", "test"]),
  })
  .parse(process.env);

Wrap your API fetches. A small helper turns every network response into validated, typed data:

async function fetchTyped<T extends z.ZodType>(url: string, schema: T): Promise<z.infer<T>> {
  const res = await fetch(url);
  return schema.parse(await res.json());
}

Format errors for users. error.flatten() produces a structure most form libraries consume directly, splitting field-level and form-level errors.

Performance Considerations

Zod validation has a cost, and it's proportional to schema complexity and data size. For most applications this is negligible, but a few habits keep it fast. Define schemas once at module scope rather than rebuilding them inside hot functions — schema construction isn't free. Prefer z.discriminatedUnion over plain z.union for tagged objects. And validate at boundaries only; don't re-parse the same trusted data as it flows deeper into your code. Once data crosses a validated edge, TypeScript's static types carry the guarantee forward for free.

FAQ

Is Zod the same as TypeScript types? No. TypeScript types are erased at compile time and do nothing at runtime. Zod schemas exist at runtime and actively check data. The connection is z.infer, which derives a static type from a schema so you get both from one definition.

Should I use parse or safeParse? Use parse when invalid data means a programming bug and crashing is acceptable (config, startup validation). Use safeParse when failure is expected and recoverable (user input, third-party APIs), since it returns a result object instead of throwing.

Does Zod add much bundle size? Zod is roughly a few kilobytes gzipped and tree-shakes reasonably well. For most web and server applications the cost is negligible against the safety it buys. If bundle size is critical on the client, validate primarily on the server and share schemas across both.

How is Zod different from Yup or Joi? The main differentiator is TypeScript-first inference. Zod derives your static types from schemas automatically, while older libraries treat types as an afterthought or require separate declarations. Zod's API is also fully typed end to end, so transformations and refinements stay type-safe.

Can I generate a schema from an existing TypeScript type? Not directly — inference flows from schema to type, not the reverse, because types don't exist at runtime. Tools exist to generate Zod schemas from JSON Schema or OpenAPI specs, which is the common path when integrating with an existing API contract.

Where should validation live in my architecture? At every trust boundary: HTTP request handlers, API response parsing, form submission, message queue consumers, and environment config. Everything inside those boundaries can then rely on TypeScript's static types with confidence.

Conclusion

Zod's value is that it makes runtime and compile-time type safety the same effort. You define a schema, you get validation and a type together, and you place that schema wherever untrusted data enters your system. Start small — validate your environment variables and one API endpoint — and expand outward. The payoff is a codebase where "this is typed as User" stops being a hopeful annotation and becomes a checked guarantee.

Sources

Related Articles