Runtime Validation with Schemas
TypeScript types erase at runtime — validate JSON and form input with Zod (or similar) so parsed data is actually typed and safe.
- typescript
- runtime-validation
TypeScript types are compile-time only. JSON.parse, fetch().json(), localStorage, and query params return data the compiler cannot verify. Schema validation libraries parse unknown into typed values — or fail with structured errors.
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
function parseUser(data: unknown): User {
return UserSchema.parse(data); // throws ZodError
}
Docs: Zod, TypeScript unknown. Alternatives: Valibot, Yup, ArkType, Superstruct — same idea.
The boundary rule
| Inside your app | At the boundary |
|---|---|
| Trust static types | Validate |
| Function args from TS callers | unknown → schema |
| React props you control | Network, storage, postMessage |
Casting as User on network data is a lie. Schema parse is an honest gate.
fetch + schema
async function loadUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json: unknown = await res.json();
return UserSchema.parse(json);
}
Safe parse for UI error states:
const result = UserSchema.safeParse(json);
if (!result.success) {
console.error(result.error.flatten());
throw new Error('Invalid user payload');
}
const user = result.data;
Forms
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
function onSubmit(raw: unknown) {
const parsed = LoginSchema.safeParse(raw);
if (!parsed.success) {
return mapZodToFieldErrors(parsed.error);
}
return login(parsed.data);
}
Single schema can feed both client validation and types via z.infer.
Composing schemas
const PaginationSchema = z.object({
page: z.number().int().positive(),
pageSize: z.number().int().max(100),
});
const UserPageSchema = z.object({
items: z.array(UserSchema),
page: PaginationSchema,
});
Transforms and brands
const DateFromIso = z.string().transform((s, ctx) => {
const d = new Date(s);
if (Number.isNaN(d.getTime())) {
ctx.addIssue({ code: 'custom', message: 'Invalid date' });
return z.NEVER;
}
return d;
});
Pair with branded types when IDs need nominal typing after parse.
Env vars
const EnvSchema = z.object({
VITE_API_URL: z.string().url(),
});
export const env = EnvSchema.parse({
VITE_API_URL: import.meta.env.VITE_API_URL,
});
Fail fast at startup instead of undefined in production.
Why not class-validator / manual ifs only?
Manual checks work for one field. Schemas scale: nested objects, arrays, unions, refinements, shared error maps. Manual type guards remain useful for tiny cases:
function isUser(v: unknown): v is User {
return (
typeof v === 'object' &&
v !== null &&
typeof (v as User).id === 'string' &&
typeof (v as User).name === 'string'
);
}
Performance notes
- Parse at boundaries, not every render.
- Reuse schema instances (module scope).
- For huge lists, consider stripping refinements in hot paths after first trust.
Footguns
parsewithout try/catch — unhandled ZodError in UI. PrefersafeParsein components.- Duplicating types — define schema first,
z.infersecond. - Validating trusted internal calls — overhead; validate untrusted edges.
- Overly strict schemas — optional API fields break clients; use
.optional()/ passthrough carefully.
Interview out-loud answer
“Types erase at runtime. I treat network and storage as unknown and parse with a schema (Zod) so success values are typed and failures are explicit. Schemas are the single source for form validation and DTO types via inference.”
Related on this site
- unknown vs any
- Typing fetch responses
- Branded types for IDs
- Type assertions safely
- Contract testing APIs
Further reading
Related guides
- Basic Types and AnnotationsPrimitives, arrays, objects, and function annotations TypeScript actually checks — plus where inference is enough and where it is not.
- Branded Types for IDsNominal-style UserId vs OrderId in TypeScript — prevent ID mixups at compile time with brands, parsers, and form boundaries.
- Conditional Types IntroT extends U ? X : Y — how TypeScript picks types from conditions, distributes over unions, and powers utility types you already use.
- Declaration Files and DefinitelyTypedHow .d.ts files describe JS to TypeScript, when to use @types packages, module augmentation, and writing minimal ambient types for untyped libs.
- Discriminated Unions for UI StateModel loading, success, and error as mutually exclusive variants so TypeScript and your UI cannot show impossible states.