Siddhant Deval
Siddhant Deval
frontend13 min read

Runtime Type Safety: Zod, Valibot & Parse-Don't-Validate

TypeScript types are erased at runtime. Any data crossing a trust boundary — API responses, localStorage, environment variables, user input — must be parsed, not cast. An `as ApiResponse` silences the compiler; it does not validate the data.

Runtime Type Safety: Zod, Valibot & Parse-Don't-Validate

Types are a specification language — not an annotation layer. That means they are design-time tools. At runtime, TypeScript types are completely erased — they exist nowhere in the compiled JavaScript. When your application calls fetch('/api/user') and casts the result as User, you have made a bet, not a guarantee. The compiler is silent. The only guarantee comes from parsing. This article is about building those guarantees correctly.


1. The Trust Boundary Problem

Every application has trust boundaries — points where data enters from an untrusted source and must be validated before use. The most common ones:

Source Example Why It's Untrusted
Network (fetch) GET /api/users → User[] Server may change schema; API may return error shape
localStorage JSON.parse(localStorage.getItem('session')) User may have modified stored data
process.env process.env.DATABASE_URL May be missing or malformed at deploy time
URL parameters new URLSearchParams(location.search).get('page') User controls the URL
JSON.parse() Parsing uploaded files or webhooks Shape is not guaranteed by the caller

The anti-pattern at every boundary is a type cast:

TYPESCRIPT
// ❌ `as User` is a compile-time silencer — it does nothing at runtime
const response = await fetch('/api/user')
const user = (await response.json()) as User

// If the API returns { error: 'Not found' }, user.name is undefined at runtime.
// TypeScript does not know. TypeScript cannot know — the types were erased.
console.log(user.name.toUpperCase())  // 💥 TypeError: Cannot read properties of undefined
 Diagram with four boundary sources on the left (Network, localStorage, process.env, URL params) all feeding into a central 'Application Code' box. Each connection has a label: the data type on entry is shown as unknown or any. A red zone is highlighted where the data crosses the boundary. A green zone on the right shows 'parsed and typed data' after a schema parser. Caption: 'Every source of external data is a trust boundary — data enters as unknown and must be parsed before the type system's guarantees apply'.
Every source of external data is a trust boundary — data enters as unknown and must be parsed before the type system

2. Parse-Don't-Validate

The Parse-Don't-Validate principle (Alexis King, 2019) articulates the key insight: validation functions return a boolean — they tell you if data is valid but hand back the same untyped value. Parse functions return a value of the target type — they encode the knowledge that validation passed into the return type itself.

TYPESCRIPT
// ❌ Validate — a boolean tells you nothing; the value is still `unknown`
function validateUser(data: unknown): boolean {
  return typeof (data as any).name === 'string' && typeof (data as any).age === 'number'
}

if (validateUser(data)) {
  // data is still `unknown` here — you have to cast
  const user = data as User  // The cast is no safer than before the check
}

// ✅ Parse — the function returns `User` or throws; knowledge is encoded in the type
function parseUser(data: unknown): User {
  if (
    typeof data !== 'object' || data === null ||
    typeof (data as any).name !== 'string' ||
    typeof (data as any).age !== 'number'
  ) {
    throw new Error(`Invalid user: ${JSON.stringify(data)}`)
  }
  return data as User  // One cast, contained inside the parser — safe
}

const user = parseUser(await response.json())
// user: User — the parse either succeeds or throws; no ambiguity

Zod and Valibot are implementations of this pattern — they provide composable schemas that act as parse functions.


3. Zod — The Standard Schema Library

3.1 Basic Schemas and Type Inference

TYPESCRIPT
import { z } from 'zod'

const UserSchema = z.object({
  id:    z.string().uuid(),
  name:  z.string().min(1),
  email: z.string().email(),
  age:   z.number().int().positive().optional(),
  role:  z.enum(['admin', 'user', 'moderator']),
})

// Derive the TypeScript type from the schema — single source of truth
type User = z.infer<typeof UserSchema>
// {
//   id: string;
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: 'admin' | 'user' | 'moderator';
// }

3.2 .parse() vs .safeParse()

TYPESCRIPT
// .parse() — throws ZodError on invalid input; use in environments where throws are acceptable
try {
  const user = UserSchema.parse(await response.json())
  // user: User — guaranteed valid
} catch (err) {
  if (err instanceof z.ZodError) {
    console.error(err.issues)  // Structured error list with paths and messages
  }
}

// ✅ .safeParse() — never throws; returns a discriminated union
const result = UserSchema.safeParse(await response.json())

if (result.success) {
  const user = result.data  // User — fully typed
  console.log(user.name)
} else {
  const errors = result.error.issues  // ZodIssue[] — structured field errors
  console.error(errors)
}
 Diagram showing .safeParse() return value as a discriminated union. Top branch labeled 'success: true' — arrow points to box { success: true; data: User } with a green checkmark. Bottom branch labeled 'success: false' — arrow points to box { success: false; error: ZodError } with a red X. Center decision diamond: '.safeParse(rawData)'. Caption: '.safeParse() returns a discriminated union — the success field drives CFA to give you a fully typed data or a structured ZodError, never both'.
.safeParse() returns a discriminated union — the success field drives CFA to give you a fully typed data or a structured ZodError, never both

3.3 Schema Composition

TYPESCRIPT
const BaseItemSchema = z.object({
  id:        z.string().uuid(),
  createdAt: z.string().datetime(),
})

// .extend() — add fields
const UserSchema = BaseItemSchema.extend({
  name:  z.string(),
  email: z.string().email(),
})

// .pick() / .omit() — subset or exclusion
const PublicUserSchema = UserSchema.omit({ email: true })

// .refine() — custom validation with a message
const AgeSchema = z.number().int().refine(n => n >= 0 && n <= 150, {
  message: 'Age must be between 0 and 150',
})

// .superRefine() — multiple issues, access to the full context
const PasswordSchema = z.string().superRefine((val, ctx) => {
  if (val.length < 8) ctx.addIssue({ code: 'too_small', minimum: 8, type: 'string', inclusive: true, message: 'Min 8 chars' })
  if (!/[A-Z]/.test(val)) ctx.addIssue({ code: 'custom', message: 'Must contain uppercase' })
})
 Pipeline diagram. Left box: 'BaseItemSchema: { id, createdAt }'. First arrow: .extend({ name, email }). Center box: 'UserSchema: { id, createdAt, name, email }'. Second arrow: .omit({ email: true }). Right box: 'PublicUserSchema: { id, createdAt, name }'. Below the pipeline: annotations showing .refine() and .superRefine() as additional validation layers applied on top of any schema. Caption: 'Zod schemas compose like functions — .extend(), .omit(), .pick(), .refine(), and .superRefine() build up a layered validation pipeline from a base schema'.
Zod schemas compose like functions — .extend(), .omit(), .pick(), .refine(), and .superRefine() build up a layered validation pipeline from a base schema

4. Valibot — When Bundle Size Matters

Zod and Valibot have near-identical APIs. The meaningful difference is bundle size:

Library Min size Gzip size Architecture
Zod (v3) ~57 KB ~14 KB Monolithic — full library in one import
Valibot ~10 KB ~3.6 KB Modular — tree-shakeable; you pay only for what you use
TYPESCRIPT
// Zod — one import, full library bundled
import { z } from 'zod'
const schema = z.object({ name: z.string() })

// Valibot — modular imports; unused validators are tree-shaken out
import * as v from 'valibot'
const schema = v.object({ name: v.string() })

// Valibot also supports named imports for maximum tree-shaking
import { object, string, parse, safeParse } from 'valibot'
const schema = object({ name: string() })
const result = safeParse(schema, rawData)
Pro Tip & Optimization

For client-side applications where bundle size is a concern (especially mobile web), prefer Valibot. For server-side Node.js applications or existing Zod-heavy codebases, Zod's larger ecosystem (React Hook Form resolver, tRPC, Drizzle) often outweighs the size difference.


5. Validating process.env at Startup

This is the highest-value, lowest-effort application of runtime parsing — it catches missing or malformed environment variables at startup, not when the affected code path first runs in production:

TYPESCRIPT
// src/env.ts
import { z } from 'zod'

const EnvSchema = z.object({
  NODE_ENV:     z.enum(['development', 'staging', 'production']),
  DATABASE_URL: z.string().url(),
  API_KEY:      z.string().min(1),
  PORT:         z.coerce.number().int().positive().default(3000),
})

// Parse at module initialization — throws on startup if env is invalid
export const env = EnvSchema.parse(process.env)

// Usage — fully typed, no `|| ''` workarounds needed
const db = createConnection(env.DATABASE_URL)  // string
const port = env.PORT  // number (coerced from string)

Summary

Concept Rule
as T at runtime Does nothing — type assertions are compile-time only; they are not runtime casts
Trust boundary Every external data source (network, env, storage, URL) needs runtime parsing
Parse-don't-validate Return the typed value, not a boolean — encode the validation result in the type
.parse() Throws ZodError on failure — use in server-side code where throws are acceptable
.safeParse() Returns a discriminated union — the correct choice at every UI/API boundary
Valibot vs. Zod Valibot is ~4× smaller with equivalent API — prefer for client-side bundles
process.env Validate with a Zod schema at startup — catch missing vars before they cause runtime errors

What's Next

In Part 10, we cover the ceiling of TypeScript mastery — Variance, Branded Types, and Exhaustiveness. These are the patterns that encode business rules into the type system so that illegal states are literally unrepresentable.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Zod#Valibot#Runtime Safety#Schema Validation
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.