Overview

TypeScript types are erased at runtime. Any value that crosses a boundary (HTTP, filesystem, env, IPC, third-party SDK) arrives as unknown and has to be parsed before it can be trusted. This page is the validation playbook. Read typescript-narrowing for the type-system side; this page covers the runtime side.

Parse, do not cast

The single rule: never as an untyped value into a typed one. Parse it with a schema.

// Bad: the cast lies. Runtime data may not match.
const user = JSON.parse(raw) as User
 
// Better: parse validates and narrows.
import { z } from "zod"
 
const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  createdAt: z.coerce.date(),
})
type User = z.infer<typeof UserSchema>
 
const user: User = UserSchema.parse(JSON.parse(raw))

If the parse fails, the throw is loud and immediate at the boundary. The alternative is a TypeError four function calls deep when something reads user.email.toLowerCase() on an object that did not have email.

Use Zod as the default

Zod is the default for new projects. It is the broadest ecosystem, has the best framework integrations (tRPC, hono, drizzle, react-hook-form), and produces good error messages.

import { z } from "zod"
 
const Invoice = z.object({
  id: z.string().uuid(),
  amountCents: z.number().int().nonnegative(),
  paidAt: z.coerce.date().nullable(),
  lineItems: z.array(z.object({
    sku: z.string(),
    quantity: z.number().int().positive(),
  })).min(1),
})
 
export type Invoice = z.infer<typeof Invoice>

z.coerce.date() accepts the ISO string the network actually sends and converts to Date. nullable(), optional(), and the difference between them matters; pick the one that matches the upstream contract. See typescript-strict-mode for the exactOptionalPropertyTypes interaction.

Use Valibot for bundle-sensitive contexts

Valibot is the lightweight alternative for client bundles. It is tree-shakable; a typical schema ships 1 to 2 KB compared to Zod’s 12 KB.

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>

Pick one schema library per project; do not mix. The API surface is similar but not interchangeable, and consumers reading the codebase should not need to keep two mental models. See forms for the form-validation usage.

Validate at every boundary

A boundary is where untyped data enters typed code. Validate at each one, not just at the outermost layer.

  • HTTP request body: validate in the handler, before passing to business logic.
  • HTTP response body: validate the upstream response before treating it as the contracted shape.
  • process.env: validate on startup; fail fast if a required variable is missing.
  • File contents: validate after JSON.parse or YAML.parse.
  • localStorage/sessionStorage: validate on read; the user may have stale or hand-edited data.
  • Messages from a worker or a third-party SDK: validate on receipt.
// Env validation on startup.
const Env = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().positive().default(3000),
  NODE_ENV: z.enum(["development", "production", "test"]),
})
 
export const env = Env.parse(process.env)

If validation fails on startup, the process exits before serving traffic. That is the right behavior; a misconfigured server should not silently serve wrong data.

Derive types from schemas, not the other way around

The schema is the source of truth. Derive the static type from it; do not write the type and the schema separately.

const User = z.object({
  id: z.string(),
  email: z.string().email(),
})
 
// Right: type follows the schema.
type User = z.infer<typeof User>
 
// Wrong: type and schema can drift.
// type User = { id: string; email: string }
// const User = z.object({ id: z.string(), email: z.string().email() })

When the schema changes, the derived type updates everywhere. When the type and schema are independent, they drift, and one ships a runtime bug while the other ships a compile error six weeks later.

Use tRPC for RPC-shaped APIs between TS clients and servers

When both ends of an API speak TypeScript, tRPC ships the schema and the type across the wire. The client gets a typed function call; the server gets a validated input.

// server.ts
export const appRouter = t.router({
  loadInvoice: t.procedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => loadInvoice(input.id)),
})
export type AppRouter = typeof appRouter
 
// client.ts
import type { AppRouter } from "./server"
const trpc = createTRPCClient<AppRouter>({ url: "/api/trpc" })
const invoice = await trpc.loadInvoice.query({ id: "inv_1" })
// invoice is typed; bad input fails to compile.

tRPC is the right tool for internal APIs between TypeScript code you own. For public APIs, use OpenAPI or a schema-first contract. See nextjs for the framework-level integration.

Never trust JSON, FormData, or query strings

Three boundary types catch teams over and over:

  • JSON.parse returns any. Parse with a schema.
  • FormData field values are string | File; missing fields are null. Parse with a schema.
  • URL query string params are always string | string[] | undefined. Coerce and validate.
const SearchParams = z.object({
  q: z.string().min(1),
  page: z.coerce.number().int().positive().default(1),
})
 
const { q, page } = SearchParams.parse(Object.fromEntries(url.searchParams))

The “I know this field is a number” assumption is wrong every time a user, a bot, or a curl command sends something else. Parse at the boundary.