Skip to main content

Zod: Stop Trusting Your Data

TypeScript promises that your User is a User. At the one place it matters most, where data enters your program, it can't keep that promise. Zod can.

Updated 20 min readMarkdown
Zod: Stop Trusting Your Data

The lie at the edge of your app

Here is some perfectly ordinary code:

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

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

console.log(user.email.toLowerCase());

It compiles. It passes review. And it is a bug waiting for a bad Tuesday.

res.json() returns any, so the : User annotation is a promise you wrote, not a fact TypeScript checked. If the backend renames email to emailAddress, ships age as a string, or returns an error body instead of a user, nothing fails where the data came in. It fails three function calls later with Cannot read properties of undefined (reading 'toLowerCase'), far from the cause and with no clue about which response was wrong.

The root issue is that TypeScript's types are erased at compile time. At runtime there are only values. Every boundary of your app hands you data of type "whatever showed up":

  • HTTP responses and webhook payloads

  • request bodies, query strings and route params

  • form submissions

  • process.env

  • localStorage, cookies and JSON files

  • output from an LLM that you asked to "reply in JSON"

There are two usual responses, and both are poor. You can trust and hope, as the snippet above does. Or you can hand-write checks (typeof x.email === "string", repeated for every field) that duplicate your types and quietly drift out of sync with them.

The better answer is to validate once, at the boundary, and let the validated result carry its type forward. That is the whole idea behind Zod, and the rest of this post shows how far it goes.

One schema, two jobs

A Zod schema is an ordinary JavaScript value that describes what your data should look like. TypeScript reads that description to produce a static type. Zod reads the same description to check real data at runtime. One definition, two jobs, and nothing to keep in sync.

import * as z from "zod";

const User = z.object({
  id: z.string(),
  email: z.email(),
  age: z.int().min(0),
});

// The static type is derived from the schema, never written by hand
type User = z.infer<typeof User>;
// { id: string; email: string; age: number }

That small file does a lot. Hover User in your editor and you see the type. Rename a field in the schema and every place that reads it fails to compile. Validate some data and the result comes back already typed as User, with no cast.

This "schema first, type derived" flip is why Zod took over. By the time Zod 4 shipped, its own release notes put it at roughly 31 million weekly npm downloads, up from 600 thousand when Zod 3 arrived in 2021.

Your first parse

A schema offers two ways to check data, and they differ in how failure reaches you. .parse() returns the typed value or throws a ZodError. .safeParse() never throws; it returns a result you narrow with success.

const input: unknown = JSON.parse('{"id":"u_1","email":"[email protected]","age":36}');

// 1. Throws on invalid data. Right when invalid data means a bug.
const user = User.parse(input);
user.email; // string

// 2. Never throws. Right when invalid data is an expected case.
const result = User.safeParse(input);
if (!result.success) {
  console.error(result.error.issues);
  return;
}
result.data.email; // string, narrowed by the success check

A good rule of thumb: use parse for data that should already be well-formed, such as your own database rows or config, so a violation crashes loudly. Use safeParse for untrusted input, such as user submissions or third-party APIs, where you want to respond gracefully. If a schema contains async refinements or transforms, reach for parseAsync and safeParseAsync.

One more behavior to know early. Parsing returns a validated copy, and by default z.object() drops keys it doesn't know about:

User.parse({ id: "u_1", email: "[email protected]", age: 36, isAdmin: true });
// { id: "u_1", email: "[email protected]", age: 36 }  <- isAdmin is gone

That quietly protects you from stray fields flowing into your database or your business logic. When you want the opposite, say rejecting extra keys outright, there is a schema for that, covered below.

Four boundaries worth guarding

The same few moves cover most of what a real app needs. Here are the four boundaries where Zod earns its keep first.

Third-party API responses

Declare only the fields you use. Everything else is stripped, so new fields upstream never bother you, and a removed or retyped field fails loudly at the boundary instead of three calls later.

const Repo = z.object({
  full_name: z.string(),
  stargazers_count: z.int(),
  pushed_at: z.iso.datetime(),
});

async function getRepo(name: string) {
  const res = await fetch(`https://api.github.com/repos/${name}`);
  if (!res.ok) throw new Error(`GitHub responded ${res.status}`);
  return Repo.parse(await res.json()); // typed, and actually verified
}

Request bodies

On the server, safeParse lets you answer bad input with a clean 400 instead of a stack trace. This handler uses web-standard Request and Response, so the same shape works in Next.js route handlers, Hono, Bun, Deno and Cloudflare Workers.

const CreatePost = z.object({
  title: z.string().trim().min(1).max(120),
  body: z.string().min(1),
  tags: z.array(z.string()).max(5).default([]),
});

export async function POST(req: Request) {
  const parsed = CreatePost.safeParse(await req.json().catch(() => null));

  if (!parsed.success) {
    return Response.json({ errors: z.flattenError(parsed.error) }, { status: 400 });
  }

  const post = await createPost(parsed.data); // parsed.data is fully typed
  return Response.json(post, { status: 201 });
}

The .catch(() => null) handles a body that isn't JSON at all; Zod then reports it as the wrong type, so one error path covers both cases.

Environment variables

Environment variables are always strings, and a missing one usually surfaces at 3 a.m. when some rare code path first reads it. Parse them once at startup and the process refuses to boot with a precise message instead.

// env.ts
const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  DATABASE_URL: z.url().refine(
    (url) => url.startsWith("postgresql://") || url.startsWith("postgres://"),
    { message: "Must be a PostgreSQL connection URL" },
  ),
  ENABLE_BETA: z.stringbool().default(false),
});

const parsed = Env.safeParse(process.env);
if (!parsed.success) {
  console.error(z.prettifyError(parsed.error));
  process.exit(1);
}

export const env = parsed.data; // env.PORT is a number, env.ENABLE_BETA a boolean

With DATABASE_URL missing, startup stops with:

✖ Invalid input: expected string, received undefined
  → at DATABASE_URL

Three details here deserve attention. z.coerce.number() turns "3000" into 3000 before validating it. DATABASE_URL uses .refine() to enforce a PostgreSQL connection string—plain z.url() accepts generic URLs like mailto:. And z.stringbool() understands "true", "1", "yes", "on" and their opposites, which is what you want from an env var. Do not use z.coerce.boolean() for this: it applies plain JavaScript truthiness, so the string "false" becomes true.

Forms

A form schema doubles as the source of both validation and the field types. With React Hook Form it plugs in as a resolver:

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const Signup = z.object({
  email: z.email({ error: "Enter a valid email address" }),
  password: z.string().min(12, { error: "Use at least 12 characters" }),
});

type SignupValues = z.infer<typeof Signup>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupValues>({ resolver: zodResolver(Signup) });

  return (
    <form onSubmit={handleSubmit((values) => api.signup(values))}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <button>Sign up</button>
    </form>
  );
}

The payoff is reuse. Import Signup on the server and the exact rules the browser showed are the rules the API enforces.

Taming messy input

Real input is messy, and Zod gives you four tools for cleaning it up. Each does a different job, and knowing which to reach for keeps schemas readable.

Tool Use it to Effect on the output type
z.coerce.* Convert a value before validating it ("42" to 42) Matches the target schema
.default() Fill in a value when the input is undefined Key becomes non-optional
.refine() Add a custom rule; the data passes through unchanged Unchanged
.transform() Reshape the validated value into something else Whatever you return

Coercion

Query strings, form fields and env vars arrive as text. Coercion converts first and validates second.

z.coerce.number().parse("42");        // 42
z.coerce.date().parse("2026-10-20");  // Date

Defaults

A default applies only when the input is undefined, and it makes the key optional on the way in but required on the way out.

const Settings = z.object({
  theme: z.enum(["light", "dark"]).default("light"),
  pageSize: z.int().min(1).max(100).default(20),
});

Settings.parse({}); // { theme: "light", pageSize: 20 }

Refinements

When a rule involves more than one field, or isn't a built-in check, use .refine(). Point path at the field that should show the error.

const Signup = z
  .object({
    password: z.string().min(12),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    path: ["confirm"],
    error: "Passwords don't match",
  });

In Zod 4, refinements live inside the schema instead of wrapping it. That means you can chain them with other methods in any order, such as z.string().refine(...).min(5), which failed to type-check in Zod 3.

Transforms

.transform() changes the shape of the data, and that is the moment input and output types diverge. z.infer gives you the output. Use z.input for what callers must send.

const Query = z.object({
  tags: z.string().transform((s) =>
    s.split(",").map((t) => t.trim()).filter(Boolean),
  ),
});

type QueryIn = z.input<typeof Query>;   // { tags: string }
type QueryOut = z.output<typeof Query>; // { tags: string[] }

Query.parse({ tags: "zod, typescript ," }); // { tags: ["zod", "typescript"] }

Simple normalizers such as .trim() and .toLowerCase() don't change the type, so they need no transform at all.

Codecs: transforms that can run backwards

A .transform() is a one-way street. Per the Zod docs, encoding through a schema that contains one throws a runtime error. When you need both directions, such as turning a date string from the network into a Date and back, use a codec.

const isoDatetimeToDate = z.codec(z.iso.datetime(), z.date(), {
  decode: (isoString) => new Date(isoString),
  encode: (date) => date.toISOString(),
});

isoDatetimeToDate.decode("2024-01-15T10:30:00.000Z"); // Date
isoDatetimeToDate.encode(new Date("2024-01-15"));      // "2024-01-15T00:00:00.000Z"

Codecs are ordinary schemas, so you can nest them inside objects and arrays. One shared schema can then convert between a JSON-friendly wire format and the richer objects your code wants, on both the client and the server. See the codecs docs for ready-made ones, including JSON strings, URLs and base64.

Building big schemas from small ones

Schemas are values, so you compose them the way you compose functions: define the strictest version once, then derive the rest.

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

const User = CreateUser.extend({
  id: z.uuid(),
  createdAt: z.iso.datetime(),
});

const UpdateUser = CreateUser.partial();         // every field optional
const PublicUser = User.omit({ email: true });   // drop a field
const UserSummary = User.pick({ id: true, name: true });

Change CreateUser and every derived schema, and every type inferred from it, follows. No copies to forget.

What happens to unknown keys

Objects come in three flavors, and the difference matters for security and for forward compatibility.

Schema Unknown keys
z.object() Stripped from the output
z.strictObject() Rejected, with an unrecognized_keys issue
z.looseObject() Passed through untouched

Stripping is a sensible default for data you consume. Use z.strictObject() for inputs where a stray field is a sign of a client bug or an attack, such as a config file where a typo should be an error rather than a silent no-op.

Unions that narrow

When data can take one of several shapes, a discriminated union checks one key first and then validates only the matching branch. TypeScript narrows on the same key, and failures point at the one branch that was meant.

const Event = z.discriminatedUnion("type", [
  z.object({ type: z.literal("signup"), email: z.email() }),
  z.object({
    type: z.literal("purchase"),
    amount: z.number().positive(),
    currency: z.enum(["USD", "EUR", "INR"]),
  }),
  z.object({ type: z.literal("refund"), purchaseId: z.uuid() }),
]);

function describe(e: z.infer<typeof Event>) {
  switch (e.type) {
    case "signup":
      return `New signup: ${e.email}`;
    case "purchase":
      return `Purchase of ${e.amount} ${e.currency}`;
    case "refund":
      return `Refund for ${e.purchaseId}`;
  }
}

Zod 4 also made discriminated unions composable, so one can sit inside another, and the discriminator itself can be a union or a pipe.

Recursive data

Trees, comment threads and nested menus refer to themselves. Zod 4 handles this with a plain getter, and the result is still an ordinary object schema with .pick(), .partial() and .extend() available.

const Category = z.object({
  name: z.string(),
  get subcategories() {
    return z.array(Category);
  },
});

type Category = z.infer<typeof Category>;
// { name: string; subcategories: Category[] }

The same trick works for mutually recursive schemas, such as a User with posts and a Post with an author, with no type casts. Note that recursive schemas can validate tree-shaped data (like nested categories or comment threads); actual cyclic JavaScript object references are a separate edge case and should not be assumed safe to parse without recursion limits.

Errors people can use

When validation fails, Zod reports everything that went wrong in one pass, and a few helpers reshape that list for whoever needs to read it. Take this schema and a deliberately bad input:

const schema = z.strictObject({
  username: z.string(),
  favoriteNumbers: z.array(z.number()),
});

const result = schema.safeParse({
  username: 1234,
  favoriteNumbers: [1234, "4567"],
  extraKey: 1234,
});

result.error.issues holds three entries, each with a code, a path and a message. Raw issues are precise, but you rarely hand them to a user as they are. Three helpers cover the common audiences.

Helper Best for What you get
z.flattenError(err) Flat forms and API 400 responses formErrors plus fieldErrors keyed by field name
z.treeifyError(err) Deeply nested data A tree that mirrors the schema
z.prettifyError(err) Logs, CLIs and startup checks A readable multi-line string

For the input above, z.flattenError returns:

{
  formErrors: [ 'Unrecognized key: "extraKey"' ],
  fieldErrors: {
    username: [ 'Invalid input: expected string, received number' ],
    favoriteNumbers: [ 'Invalid input: expected number, received string' ]
  }
}

And z.prettifyError returns text you can drop straight into a log line:

✖ Unrecognized key: "extraKey"
✖ Invalid input: expected string, received number
  → at username
✖ Invalid input: expected number, received string
  → at favoriteNumbers[1]

The older z.formatError() is deprecated in favor of z.treeifyError().

Writing your own messages

Zod 4 replaced a pile of overlapping options with one error parameter. Pass a string for a fixed message, or a function when the message depends on what went wrong.

const Age = z
  .int({ error: "Age must be a whole number" })
  .min(18, { error: "You must be 18 or older" });

const Name = z.string({
  error: (issue) =>
    issue.input === undefined ? "This field is required" : "Not a string",
});

For whole-app translation, Zod ships locale packs that you switch on once, at startup:

import * as z from "zod";
import { fr } from "zod/locales";

z.config(fr());

What changed in Zod 4 (and beyond)

If you last evaluated Zod during the v3 era, the 4.x series is fundamentally faster and leaner. Runtime parsing is 6–14x faster, gzipped bundle size dropped from 12.5 kB to 5.4 kB (and under 2 kB with Zod Mini), and TypeScript type instantiation depth dropped by over 90%, eliminating editor sluggishness on complex schemas.

Key additions

Native JSON Schema. Zod 4 converts schemas directly to JSON Schema without auxiliary packages. Metadata declared with .describe() or .meta() carries over seamlessly for OpenAPI documentation and LLM tool calling:

const mySchema = z.object({
  firstName: z.string().describe("Your first name"),
  lastName: z.string().meta({ title: "last_name" }),
  age: z.number().meta({ examples: [12, 99] }),
});

z.toJSONSchema(mySchema);

Zod Mini for minimal bundles. Method-chained schemas resist tree-shaking. Zod Mini exports functional equivalents so unused validators are dropped from your bundle:

import * as z from "zod/mini";

z.array(z.number()).check(z.minLength(5), z.maxLength(10));

JIT compilation (z.compile). When high-throughput event streams or tight server loops profile validation as a bottleneck, z.compile() generates specialized parsers that run 3–9x faster:

const Player = z.object({ username: z.string(), bio: z.string(), xp: z.number() });
const CompiledPlayer = z.compile(Player);
CompiledPlayer.parse(data);

For yes/no validation where building an error object is unnecessary, .validate() narrows the type directly (if (Player.validate(data)) { ... }).

The ecosystem around it

Zod's biggest advantage may not be Zod itself. Whatever you are building, someone has probably already plugged a Zod schema into it. The official ecosystem page lists dozens of libraries, and these are the families you are most likely to meet.

You need Look at
End-to-end typed APIs tRPC, oRPC
Forms React Hook Form (via its Zod resolver), Conform, Superforms, Regle
Frameworks & OpenAPI nestjs-zod, fastify-zod-openapi, zod-openapi
Code generation Orval, Kubb, prisma-zod-generator
Testing & Config zod-schema-faker, zod-config

The Zod docs also ship an MCP server and an llms.txt file, so a coding agent can search them instead of guessing at an API from stale training data.

Standard Schema: the reason your choice is less risky

Not long ago, every tool that accepted a schema needed a separate adapter for each validation library. Standard Schema changed that. It is a tiny shared interface, designed by the creators of Zod, Valibot and ArkType, in which every compliant schema exposes a ~standard property with a validate function. Zod has implemented it since 3.24.0, Valibot since v1.0 and ArkType since v2.0.

import type { StandardSchemaV1 } from "@standard-schema/spec";

// Any library can accept Zod, Valibot or ArkType schemas without importing them
async function validateWith<T extends StandardSchemaV1>(schema: T, data: unknown) {
  return schema["~standard"].validate(data);
}

The practical effect is that a router, form library or SDK can accept your schema without shipping an adapter for each validator. A companion spec, Standard JSON Schema, does the same for JSON Schema conversion, and Zod supports it from 4.2. For you, this lowers lock-in: choosing Zod no longer means choosing a narrower set of tools.

Zod, Valibot or ArkType?

All three do the same core job, so choose by the constraint that actually bites you rather than by hype.

Zod Valibot ArkType
Style Chained methods, or functions in Zod Mini Small functions combined with pipe Schemas written in TypeScript-like syntax
Known for The largest ecosystem and built-in JSON Schema Very small bundles Mirroring TypeScript types at runtime
Standard Schema Yes, since 3.24 Yes, since 1.0 Yes, since 2.0
Pick it when You want the default with the widest tool support Every kilobyte counts in the browser You think in TypeScript types and want them reused as validators

ArkType allows schemas to be written directly in TypeScript-like type expressions (type({ id: "string", visibility: "'public' | 'private'" })), while Valibot optimizes aggressively for browser bundle size (often under 1 kB). Rather than debating vendor micro-benchmarks, choose based on your actual architectural constraints: bundle size for frontend clients, familiar syntax for type-heavy teams, or Zod for maximum ecosystem tooling.

A sensible way to decide:

  • Unsure? Start with Zod. The docs, tutorials and integrations are deepest, and someone has already asked your question.

  • Shipping to browsers with a tight budget? Look hard at Valibot, or at Zod Mini.

  • Love TypeScript's type syntax? ArkType will feel like home.

  • Worried about lock-in? Standard Schema means most modern tools accept any of the three, though your schemas themselves would still need rewriting.

Eight habits that keep Zod codebases healthy

  1. Validate at the boundary, then trust the types. Parse once where data enters, then pass typed values inward. Re-validating inside every function adds cost and noise.

  2. Define schemas once, at module scope. Building a schema isn't free, so avoid constructing them inside functions or render loops. Tools like babel-plugin-zod-hoist exist precisely because repeated schema construction adds up.

  3. Keep input and output types straight. z.infer gives the output. When a schema has defaults, coercion or transforms, use z.input for what callers send.

  4. Remember that Zod checks shape, not safety. A valid email isn't a verified one, and a string that passes z.string() isn't escaped for HTML or SQL. Escaping, parameterized queries and authorization still belong in their usual places.

  5. Prefer safeParse on untrusted input. Convert issues with z.flattenError or z.prettifyError rather than returning raw error objects to clients.

  6. Expect upgrades to tighten, not loosen. Releases in the 4.4 and 4.5 series made several checks stricter on purpose. String length limits count Unicode code points instead of UTF-16 units, __proto__ keys are always stripped, and z.iso.datetime() precision can be explicitly pinned (e.g. { precision: 0 } to enforce seconds without fractional seconds). Run your tests when you bump the version.

  7. Turn on strict in your tsconfig. Zod requires it, and it is tested against TypeScript 5.5 and later.

  8. Retire deprecated forms gradually. z.string().email() and friends still work, but the top-level z.email() style is the future, and the old method forms are due to be removed in the next major version.

Start with one boundary

Go back to the code from the top of this post. The fix is one changed line:

const res = await fetch("/api/users/42");
const user = User.parse(await res.json());

console.log(user.email.toLowerCase());

The program looks almost the same, but user is now a User because something checked, not because someone promised. If the backend renames email tomorrow, you learn about it at the boundary, from a message that names the field, instead of from a confused stack trace three calls deep.

That is the real value of Zod. A single schema becomes your validator, your TypeScript type, your form rules, your API contract and your JSON Schema, and they can never drift apart because they are the same object. You don't need to rewrite anything to begin. Pick the boundary that hurts most and put one schema on it.

  • Install it with npm install zod and confirm strict is on in your tsconfig

  • Put a schema on the one boundary that has bitten you most (an API client, a route handler or process.env)

  • Replace the hand-written type with z.infer<typeof YourSchema>

  • Parse process.env once at startup and let a bad deploy fail fast

  • Return z.flattenError(error) from your API's 400 responses

  • Share one form schema between the browser and the server

  • Measure before you reach for z.compile()


Published via ZyVOP — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.

About the author

  • One platform to write, cross-post to Dev.to, Medium, WordPress, Hashnode, and Bluesky, and protect your SEO with canonical links. Zero paywalls and full content ownership.

No comments yet. Be the first to comment.