Runtime validation at the edges

Why types vanish at runtime, deriving the static type from one schema, and parsing API responses, forms and environment variables.

Types disappear at runtime

// each of these compiles, and none of them is checked at runtime
const user = JSON.parse(raw) as User;                 // a claim the compiler believes
const env = process.env as Record<string, string>;    // missing keys are undefined
declare const settings: Settings;                     // trust me - no code is emitted

// the honest version: accept unknown, then prove the shape
function isUser(value: unknown): value is User {
  if (typeof value !== 'object' || value === null) return false;
  const candidate = value as { id?: unknown; name?: unknown };
  return typeof candidate.id === 'string' && typeof candidate.name === 'string';
}

function parseUser(raw: string): User {
  const value: unknown = JSON.parse(raw);
  if (!isUser(value)) throw new TypeError('payload is not a User');
  return value;
}

// generic checks compose, but hand-written ones grow quickly
const isStringArray = (value: unknown): value is string[] =>
  Array.isArray(value) && value.every(item => typeof item === 'string');

Compilation removes every type, so the only thing that can refuse bad data at runtime is code you actually wrote. The boundaries - HTTP responses, form posts, environment variables, storage, queue messages - are where that code belongs. Inside the program, plain types are enough.

One schema, one source of truth

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email().optional(),
  role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
  createdAt: z.coerce.date()          // accepts an ISO string or a timestamp
});

// the static type is derived from the schema, so the two cannot drift apart
type User = z.infer<typeof UserSchema>;

const parsed = UserSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
  for (const issue of parsed.error.issues) {
    console.error(issue.path.join('.'), issue.message);
  }
} else {
  register(parsed.data);              // typed User, proven at runtime
}

// reuse a small schema inside a bigger one
const SearchSchema = z.object({
  q: z.string().trim().min(1),
  page: z.coerce.number().int().positive().default(1)
});
type Search = z.infer<typeof SearchSchema>;

// fields that arrive as strings still land as the types your code expects
const page = SearchSchema.parse({ q: 'types', page: '2' }).page;   // 2, a number
EdgeWhat can be wrongValidate with
HTTP response bodyWrong shape, error payload, empty bodyA schema applied to unknown
Form submissionMissing fields, wrong types, invalid formatThe same schema on the server
Environment variablesMissing key, empty string, unparsable numberA schema parsed once at start-up
localStorage / cookiesUser-edited values, older versionsA schema with defaults and a version field
Webhook payloadUnexpected event type, replayed bodyA discriminated union keyed by event name

Config and forms

// environment: fail at start-up with a readable list, not on first use
const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  PORT: z.coerce.number().int().default(3000),
  NODE_ENV: z.enum(['development', 'test', 'production'])
});

export const env = EnvSchema.parse(process.env);

// forms: validate on the server, and treat browser output as a hint
const SignupSchema = z.object({
  email: z.string().email(),
  password: z.string().min(12)
});

type SignupField = keyof z.infer<typeof SignupSchema>;
type FieldErrors = Partial<Record<SignupField, string>>;

// turn a schema failure into per-field messages the form can render
function fieldErrors(error: z.ZodError): FieldErrors {
  const out: FieldErrors = {};
  for (const issue of error.issues) {
    const key = issue.path[0] as SignupField;
    out[key] ??= issue.message;       // the first message wins, as users expect
  }
  return out;
}

const submission = SignupSchema.safeParse(Object.fromEntries(formData));
const errors = submission.success ? {} : fieldErrors(submission.error);
⚠️
Never treat browser validation as a control. Attributes such as required and any client-side schema can be bypassed with a plain HTTP request, so the server-side parse is the boundary that actually protects the data.

FAQ

Do I need a schema for every type?
No - only where data enters the program: HTTP, forms, environment variables, storage and message queues. Inside the program the data is already typed, and schemas there only add noise.
Zod, Valibot or hand-written guards?
A library gives composable error reporting and derives the static type from one declaration, so the two cannot drift. Hand-written guards are fine for one small shape and become repetitive past two or three.

Typing asynchronous and external data Real-world modelling: errors, API and state

Last refreshed 2026-09-18.