Overview

TypeScript types are erased at runtime, so any value crossing a boundary (HTTP, filesystem, env, IPC, third-party SDK) arrives untyped and must be parsed before it is trusted. A schema library validates the value and produces the static type from one definition. The compile-time side is in typescript-narrowing. For the library choice, see zod-vs-valibot.

Parse, do not cast

JSON.parse(raw) as User asserts a shape and checks nothing. Parse with a schema; a bad payload then fails loudly at the boundary instead of as a TypeError several calls deep.

import { z } from "zod"
 
const User = z.object({
  id: z.uuid(),
  email: z.email(),
  createdAt: z.coerce.date(),
})
type User = z.infer<typeof User>
 
const user = User.parse(JSON.parse(raw))

Zod 4 moved string formats to top-level functions (z.uuid(), z.email(), z.url()); the old method forms (z.string().email()) are deprecated. Use safeParse where failure is expected, and z.prettifyError(error) for a readable message.

Derive the type from the schema

The schema is the source of truth. A hand-written type next to a schema drifts, so one ships a runtime bug and the other a compile error weeks later. Use z.infer<typeof Schema>, or v.InferOutput<typeof Schema> in Valibot.

Default to Zod; choose Valibot for bundle size

Zod has the widest ecosystem and framework integrations. Valibot’s modular API tree-shakes to a smaller bundle, and Zod 4 narrowed the gap with zod/mini, so measure both in your own bundle. Pick one per project. Standard Schema (standardschema.dev) is a shared interface that lets a tool accept any conforming validator without an adapter; check that the library you pick implements it before relying on that.

import * as v from "valibot"
 
const Invoice = v.object({
  id: v.pipe(v.string(), v.uuid()),
  amountCents: v.pipe(v.number(), v.integer(), v.minValue(0)),
})
type Invoice = v.InferOutput<typeof Invoice>

Validate at every boundary

  • Request bodies: in the handler, before business logic.
  • Upstream responses: do not treat a third-party reply as the contracted shape.
  • process.env: once at startup, so a missing variable stops the process before it serves traffic.
  • Parsed files (JSON.parse, YAML) and localStorage/sessionStorage reads, which can be stale or hand-edited.
  • Messages from workers and SDK callbacks.
const Env = z.object({
  DATABASE_URL: z.url(),
  PORT: z.coerce.number().int().positive().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]),
})
export const env = Env.parse(process.env)

Treat FormData, query strings, and JSON as strings and unknowns

JSON.parse returns any. FormData values are string | File, and a missing field is null. Query parameters are string | string[] | undefined. Coerce and validate all of them.

const Search = z.object({
  q: z.string().min(1),
  page: z.coerce.number().int().positive().default(1),
})
const { q, page } = Search.parse(Object.fromEntries(url.searchParams))

z.coerce.date() converts the ISO string the network actually sends. Choose optional(), nullable(), or nullish() to match the upstream contract, and see typescript-strict-mode for how exactOptionalPropertyTypes interacts. For forms, see forms; for server handlers, see nextjs.