---
title: "TypeScript: Runtime Validation"
slug: "typescript-runtime-validation"
category: "coding"
tags: ["coding", "typescript", "validation", "zod", "boundaries"]
status: "stable"
last_updated: 2026-05-14
summary: "Validate at every boundary with Zod (default) or Valibot (lightweight); never trust untyped JSON; derive types with z.infer."
related: ["[[coding/typescript]]", "[[coding/typescript-narrowing]]", "[[coding/typescript-strict-mode]]", "[[frontend/nextjs]]", "[[frontend/forms]]"]
---

## 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 [[coding/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.

```ts
// 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.

```ts
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 [[coding/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.

```ts
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 [[frontend/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.

```ts
// 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.

```ts
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.

```ts
// 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 [[frontend/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.

```ts
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.

## Related

- [[coding/typescript]]
- [[coding/typescript-narrowing]]
- [[coding/typescript-strict-mode]]
- [[frontend/nextjs]]
- [[frontend/forms]]
