Parse, Don't Assert

Type assertions (as SomeType) are a promise to the compiler that you can't keep at runtime. Zod takes the opposite approach: define the schema once, parse the input, and get a properly typed value back — or a structured error. The schema is the type. No duplication, no drift.

🤔 Sound familiar?
  • You're casting res.json() as SomeApiType and hoping the API matches your type definition
  • Your environment variable parsing is a series of process.env.X as string assertions
  • You write a TypeScript interface AND a Yup schema AND keep them manually in sync
  • A form submits with the right types in dev but blows up in production with different data

Zod eliminates the gap between “what TypeScript thinks this is” and “what it actually is at runtime” — especially at API boundaries.

This article targets Zod 4. If you're still on v3, most of this transfers directly — the parsing model didn't change — but the string-format validators did: z.string().email() and friends are now soft-deprecated in favour of top-level functions (z.email(), z.uuid(), z.url()), which are faster to type-check and tree-shake better since the checker doesn't have to thread the format through the whole ZodStringchain. They still work if you have v3 code lying around; there's no urgency to rewrite it, but reach for the top-level form in anything new.

The Core Pattern

import { z } from 'zod';

// 1. Define the schema — Zod 4 style: top-level format validators
const UserSchema = z.object({
  id: z.uuid(),
  name: z.string().min(1).max(100),
  email: z.email(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.iso.datetime(),
});

// 2. Infer the TypeScript type — no duplication
type User = z.infer<typeof UserSchema>;

// 3. Parse at the boundary — throws ZodError if invalid
const user = UserSchema.parse(await res.json());
// user is fully typed as User — TS trusts it because Zod checked it

// 4. Safe parse — returns { success: true, data } | { success: false, error }
const result = UserSchema.safeParse(rawInput);
if (result.success) {
  console.log(result.data.email); // typed
} else {
  console.error(result.error.flatten()); // structured errors
}

flowchart LR
    input["Raw Input
(unknown)"] --> schema["Zod Schema
.parse()"]
    schema -->|valid| typed["Typed Value
User"]
    schema -->|invalid| error["ZodError
.flatten()"]

    style typed fill:#16a34a,color:#fff,stroke:#15803d
    style error fill:#dc2626,color:#fff,stroke:#b91c1c
    style schema fill:#0078D4,color:#fff,stroke:#005a9e

Environment Variables

Zod's most universally useful application: validated, typed env variables that fail at startup instead of randomly in production.

// env.ts — parse once at startup
import { z } from 'zod';

const EnvSchema = z.object({
  DATABASE_URL: z.url(),
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.url(),
  REDIS_URL: z.string().optional(),
  PORT: z.coerce.number().default(3000), // coerce converts "3000" string to number
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});

export const env = EnvSchema.parse(process.env);
// Throws at startup with a clear error if any required var is missing or malformed
// env.PORT is typed as number, not string | undefined

API Response Validation

import { z } from 'zod';

const PaginatedResponseSchema = <T extends z.ZodTypeAny>(itemSchema: T) =>
  z.object({
    items: z.array(itemSchema),
    total: z.number().int().nonnegative(),
    page: z.number().int().positive(),
    pageSize: z.number().int().positive(),
  });

const ArticleSchema = z.object({
  id: z.uuid(),
  title: z.string(),
  publishedAt: z.iso.datetime().nullable(),
  tags: z.array(z.string()),
});

// Reusable: PaginatedResponse<Article>
const ArticleListSchema = PaginatedResponseSchema(ArticleSchema);
type ArticleList = z.infer<typeof ArticleListSchema>;

async function fetchArticles(page: number): Promise<ArticleList> {
  const res = await fetch(`/api/articles?page=${page}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  
  // Throws if the API returns unexpected shape
  return ArticleListSchema.parse(await res.json());
}

Form Validation with React Hook Form

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

const SignUpSchema = z.object({
  email: z.email('Invalid email address'),
  password: z
    .string()
    .min(8, 'At least 8 characters')
    .regex(/[A-Z]/, 'At least one uppercase letter')
    .regex(/[0-9]/, 'At least one number'),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  { message: 'Passwords do not match', path: ['confirmPassword'] }
);

type SignUpForm = z.infer<typeof SignUpSchema>;

function SignUpForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<SignUpForm>({
    resolver: zodResolver(SignUpSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}
      {/* ... */}
    </form>
  );
}

Transformations and Preprocessing

// .transform() converts the parsed value to a different type
const DateStringSchema = z.iso.datetime().transform((s) => new Date(s));
type ParsedDate = z.infer<typeof DateStringSchema>; // Date — not string!

// .preprocess() runs before validation — useful for coercion
const FlexibleNumberSchema = z.preprocess(
  (val) => (typeof val === 'string' ? Number(val) : val),
  z.number()
);

// .pipe() chains schemas
const TrimmedEmailSchema = z.string().transform((s) => s.trim()).pipe(z.email());

// Practical: parse a query param that might be a string number
const PageSchema = z.coerce.number().int().positive().default(1);
const page = PageSchema.parse(searchParams.get('page')); // "2" → 2

Error Handling

import { z } from 'zod';

const result = UserSchema.safeParse(unknownData);

if (!result.success) {
  // .flatten() — simple field → messages map
  const flat = result.error.flatten();
  // { fieldErrors: { name: ['Required'], email: ['Invalid email'] }, formErrors: [] }

  // .format() — nested structure matching the schema shape
  const formatted = result.error.format();

  // Individual issues
  result.error.issues.forEach((issue) => {
    console.log(issue.path.join('.'), issue.message, issue.code);
  });
}

// In a Next.js Server Action:
export async function createUser(formData: FormData) {
  const result = UserSchema.safeParse(Object.fromEntries(formData));
  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }
  // result.data is typed as User
  await db.user.create({ data: result.data });
}

Pitfalls

Using .parse() in request handlers

parse() throws a ZodError — which becomes a 500 in most frameworks. Use safeParse() in request handlers and return 400 on validation failure. Reserve parse() for startup-time validation (env vars) where throwing is the correct behaviour.

Inferring after transform

z.infer<typeof Schema> gives you the output type after transforms. If you need the input type (what goes into parse()), use z.input<typeof Schema>.

Schema reuse vs recreation

Define schemas as module-level constants, not inside components or route handlers. Recreating schemas on every render is wasteful and breaks referential equality for memoisation.

Migrating from Zod 3: .string().email()still runs, it just isn't the model anymore

The chained form isn't removed — it's a thin wrapper around the same top-level check now, kept around so v3 codebases don't break on upgrade. The reason to actually go rewrite it isn't correctness, it's that Zod 4's parsing engine and TypeScript performance work were built around the top-level primitives; the chained form is the compatibility shim, not the fast path. New code should default to z.email()/z.uuid()/z.url()/z.iso.datetime() — treat the old chain as something you migrate opportunistically, not a fire drill.