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.

The Core Pattern

import { z } from 'zod';

// 1. Define the schema
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().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.string().url(),
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().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.string().uuid(),
  title: z.string(),
  publishedAt: z.string().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.string().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.string().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.string().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.