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| Edge | What can be wrong | Validate with |
|---|---|---|
| HTTP response body | Wrong shape, error payload, empty body | A schema applied to unknown |
| Form submission | Missing fields, wrong types, invalid format | The same schema on the server |
| Environment variables | Missing key, empty string, unparsable number | A schema parsed once at start-up |
localStorage / cookies | User-edited values, older versions | A schema with defaults and a version field |
| Webhook payload | Unexpected event type, replayed body | A 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.
Related
Typing asynchronous and external data Real-world modelling: errors, API and state
Last refreshed 2026-09-18.