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) andlocalStorage/sessionStoragereads, 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.