ESC

Type to search the knowledge base.

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.

intermediate3 min read
  • 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;

See typing fetch responses.

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

  1. parse without try/catch — unhandled ZodError in UI. Prefer safeParse in components.
  2. Duplicating types — define schema first, z.infer second.
  3. Validating trusted internal calls — overhead; validate untrusted edges.
  4. 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.”

Further reading

Related guides