Runtime Data Validation: Trusting Data You Didn't Type-Check
Understand why TypeScript's compile-time types cannot protect you from untrusted runtime data (API responses, form input, localStorage, URL params), and use a schema library like Zod to validate that data at the boundary, turning unknown into a value you can trust with the same confidence as a hand-written type.
TypeScript types disappear the moment your code is compiled to JavaScript. When you write const data: User = await res.json(), you have not actually checked anything — you have told the compiler 'trust me, this is a User', and the compiler believes you, silently, with zero runtime cost and zero runtime safety. If the API changes a field name, returns null instead of a string, or your backend team ships a bug, your React app will happily destructure data.email.toLowerCase() and crash with 'Cannot read properties of undefined' deep inside a component, far from where the bad data entered. Runtime validation closes this gap: instead of asserting a type, you define a schema and ask a library to actually inspect the value at the moment it crosses a trust boundary — an API response, a form submission, a URL query string, localStorage, a websocket message — and only let it through if it matches. This lesson builds that boundary with Zod, the schema library most commonly paired with TypeScript in production React apps.
Compile-Time Types vs. Runtime Validation
A TypeScript interface or type is a promise you make to the compiler about the *shape* of a value, checked only while your code is being compiled. Once that code runs in a browser, there is no type system left — everything is just JavaScript values. fetch() returns Promise<any> from .json(), which means TypeScript will let you assign that result to any type you like, including a type that is completely wrong, and it will not complain. This is sometimes called 'the any escape hatch that lives inside every network call.'
Runtime validation is a different, complementary tool. Instead of describing a *type*, a schema describes a *check* — a function that takes an unknown value and returns either 'yes, this matches, here it is with a real type' or 'no, and here is exactly what was wrong.' The schema is defined once, and from it you get both: a TypeScript type (via type inference) and a runtime validator, guaranteed to stay in sync because they come from the same source.
• Compile-time types: checked only during tsc/build, erased before the browser ever runs the code, zero runtime cost, zero runtime protection. • Runtime validation: checked every time the data actually arrives, has a small runtime cost, and is the only thing that can catch a live API mismatch. • The right pattern is 'validate at the boundary, trust everywhere else' — do the check once when data enters your app, then let the rest of your codebase use plain, fast, unchecked TypeScript types.
Schemas: One Definition, Two Outputs
A schema library like Zod lets you describe the shape of a value declaratively — z.object({ id: z.number(), email: z.string().email() }) — and that single declaration gives you two things: schema.parse(value) / schema.safeParse(value) to actually validate a value at runtime, and z.infer<typeof schema> to derive the exact matching TypeScript type. You never write the interface by hand and hope it matches your validation logic; the type *is* the validation logic, inferred automatically.
This matters enormously for API boundaries specifically. Your backend contract can drift — a field gets renamed, a nullable field starts being required, an enum gets a new value — and if you only have a hand-written TypeScript interface, none of that drift is ever caught, because interfaces are compile-time-only fictions. A schema catches it the moment real data flows through it, in development and in production.
• z.string(), z.number(), z.boolean(), z.date() — primitive validators. • z.object({...}) — validates an object's shape, rejecting extra/missing/wrong-typed keys according to configuration. • z.array(itemSchema) — validates every element of an array against a sub-schema. • z.enum([...]) / z.union([...]) — validates that a value is one of a fixed set of options or shapes. • .optional(), .nullable(), .default(value) — modifiers for fields that may be absent, null, or should fall back to a default.
parse() vs safeParse(): Throwing vs Returning a Result
Zod (and libraries like it — Valibot, Yup, io-ts follow similar patterns) gives you two ways to run a schema against a value. schema.parse(value) either returns the validated, typed value or *throws* an exception (ZodError) describing exactly which fields failed and why. schema.safeParse(value) never throws — it always returns a discriminated-union result object: { success: true, data } or { success: false, error }. You check .success and branch, which is almost always the better choice at an API or form boundary because you get validation failures as ordinary control flow instead of exceptions you have to remember to catch.
The rule of thumb used across production React codebases: use safeParse at boundaries where failure is expected and must be handled gracefully — API responses, form submissions, URL params, environment variables read at startup. Use parse (which throws) only in contexts where an error boundary or a top-level try/catch is the correct place to funnel the failure, or in tests where you *want* a hard failure on bad data.
• parse() — throws ZodError on failure; good for 'this should never fail, and if it does, crash loudly.' • safeParse() — returns { success, data | error }; good for 'this can fail, and I need to show the user something reasonable.' • ZodError.issues (or .flatten()) gives a structured, field-by-field list of what went wrong — perfect for mapping directly onto form field error messages.
Form Validation: The Same Schema, a Different Entry Point
The exact same schema-based approach that protects you from untrusted API responses also protects you from untrusted user input in forms — and, crucially, it's often the *same schema*. If your API expects { email: string, age: number } for creating a user, you can define one Zod schema and use it both to validate the create-user form client-side (giving instant field-level feedback) and to validate the response when you re-fetch that user later. Libraries like React Hook Form ship first-class Zod resolvers (@hookform/resolvers/zod) specifically so you don't have to write validation logic twice.
This is the deeper idea behind 'runtime data validation' as a discipline: any time data crosses a trust boundary — coming in from a user, a network response, browser storage, or a third-party script — your compile-time types are not enough, because none of those sources were checked by your compiler. A schema is the thing that actually stands at the door.
Defining and Using a Zod Schema
z.infer<typeof Schema> means the type and the validator can never drift apart — change one field in the schema and the type updates automatically. safeParse never throws, which makes it safe to call in a render path or an event handler without wrapping it in try/catch. Prefer .int().positive()-style chained refinements over a bare z.number() when the domain has real constraints — 'any number' is rarely actually correct for an id.
import { z } from "zod";
// 1. Define the schema — the single source of truth
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "member", "guest"]),
avatarUrl: z.string().url().nullable(),
});
// 2. Derive the TypeScript type from the schema — never write it by hand
type User = z.infer<typeof UserSchema>;
// 3. Validate an unknown value against the schema
const result = UserSchema.safeParse(someUnknownValue);
if (result.success) {
// result.data is fully typed as User here
console.log(result.data.email);
} else {
// result.error.issues is a structured list of what went wrong
console.error(result.error.flatten());
}Code Examples
The raw response is typed as unknown (not asserted as Post[]) until it has actually passed safeParse. Only the validated result.data — never the raw fetch result — is returned to callers, so every component that consumes fetchPosts() gets data it can trust without re-checking. Notice the type is unknown, not any and not Post[]. unknown forces you to narrow the value (via the schema) before you can use it — TypeScript will not let you call .title on an unknown value. This is the type-system's way of enforcing 'validate before you trust.' const raw: unknown = await res.json();
zodResolver wires the same schema-based validation into React Hook Form, so onSubmit only ever receives data that has already passed the schema. Field-level error messages come straight from the schema's own error strings (e.g. .email("Enter a valid email address")), so you write each validation rule and its message exactly once.
Common mistake: A common mistake is validating on submit but then trusting the same values elsewhere without re-validation — e.g. reading a raw, unvalidated value out of localStorage on next load and assuming it still matches the schema. Anything read back from storage should be re-validated the same way an API response is.
import { z } from "zod";
const PostSchema = z.object({
id: z.number(),
title: z.string().min(1),
body: z.string(),
authorId: z.number(),
publishedAt: z.string().datetime().nullable(),
});
const PostListSchema = z.array(PostSchema);
export type Post = z.infer<typeof PostSchema>;
async function fetchPosts(): Promise<Post[]> {
const res = await fetch("/api/posts");
if (!res.ok) {
throw new Error(`Failed to fetch posts: ${res.status}`);
}
// .json() returns 'any' — this is genuinely unknown data until proven otherwise
const raw: unknown = await res.json();
const result = PostListSchema.safeParse(raw);
if (!result.success) {
// Log the *shape* mismatch for observability, but don't crash the whole app
console.error("API contract violation on /api/posts:", result.error.flatten());
throw new Error("Received malformed post data from the server");
}
// From here on, 'posts' is trusted, typed Post[] — every consumer downstream
// can rely on it without re-checking a single field.
return result.data;
}import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const SignupSchema = z.object({
email: z.string().email("Enter a valid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
age: z.coerce.number().int().min(13, "You must be at least 13 years old"),
});
type SignupValues = z.infer<typeof SignupSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(SignupSchema),
});
const onSubmit = async (values: SignupValues) => {
// 'values' is already fully validated and typed — no re-checking needed
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(values),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} placeholder="Email" />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input type="password" {...register("password")} placeholder="Password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
<input type="num
…The Runtime Validation Boundary
Every value that enters your React app from outside its own TypeScript-checked code should pass through this exact funnel before any component touches it.
┌───────────────────┐ ┌────────────────────┐ ┌───────────────────────┐
│ unknown API │ │ Schema │ │ Trusted typed data │
│ data │ ──▶ │ validation │ ──▶ │ │
│ (res.json() = any) │ │ (Zod .safeParse()) │ │ (Post[], User, ...) │
└───────────────────┘ └────────────────────┘ └───────────────────────┘
│ │
│ ▼
│ success:false ──▶ reject, log, show fallback UI
│ (never reaches components)
▼
also applies to: form input, localStorage reads, URL query params, websocket
messages — anything that crosses from "outside your program" to "inside it."Important Rules
Never assign the result of .json(), localStorage.getItem(), or a URL param directly to a typed variable — treat it as unknown until a schema has validated it.
Prefer safeParse at boundaries where failure is expected and recoverable; reserve parse for cases where a thrown error is the desired behavior (e.g. caught by an error boundary).
Derive TypeScript types from schemas with z.infer, never hand-write a parallel interface that can drift out of sync with the validator.
Validate once at the boundary, then pass the validated, typed value through the rest of the app — don't re-validate the same trusted data repeatedly deep in the component tree.
Log schema validation failures (with enough context to debug, but without leaking sensitive user data) so contract drift between frontend and backend is visible in observability tools, not just a silent crash.
Why Teams Adopt This After Their First Bad Deploy
Most teams don't add runtime validation proactively — they add it after a backend change silently breaks the frontend in production because a field went from required to optional, or an enum value got renamed, and nothing caught it until users started seeing blank screens or .toLowerCase() of undefined crashes in error monitoring. Once a schema like Zod sits at every fetch boundary, that entire class of bug becomes a clean, loggable validation failure with a specific field and reason, instead of an untraceable runtime crash three components deep. Many teams eventually generate their Zod schemas directly from an OpenAPI spec or tRPC router so the frontend schema and backend contract are mechanically kept in sync, removing the manual-drift risk entirely.
const data = (await res.json()) as User;
const data = UserSchema.parse(await res.json());
as User is a type assertion — it tells the compiler to trust you and does nothing at runtime. If the API returns something that doesn't match User, this line succeeds silently and the bad data crashes something later, far from the fetch call. UserSchema.parse(...) actually inspects the value and fails immediately, at the point of entry, with a clear error.
if (!result.success) { return; } // silently drop invalid data if (!result.success) { logValidationFailure(result.error); return fallbackData; }
Silently discarding invalid data on safeParse failure hides real production bugs — a backend contract change will go completely unnoticed until a user reports something being missing. Always log the failure with enough detail to diagnose it, and return an explicit fallback so the UI degrades predictably instead of just doing nothing.
Why does const data = (await res.json()) as User; fail to actually protect your app from a malformed API response? A. Because as User is a type assertion, checked only at compile time — it performs no runtime check and does nothing if the actual data doesn't match User. B. Because fetch cannot return JSON, only text. C. Because TypeScript interfaces cannot describe objects with more than five fields. D. Because res.json() always throws on malformed responses, making the assertion redundant.
A. Because as User is a type assertion, checked only at compile time — it performs no runtime check and does nothing if the actual data doesn't match User. — Type assertions like as User exist purely for the compiler and are erased at runtime — they don't run any code that inspects the actual value, so a mismatched shape passes through completely unchecked.
[code] What is the key difference between schema.parse(value) and schema.safeParse(value) in Zod? A. parse throws a ZodError on failure; safeParse always returns a { success, data | error } result object and never throws. B. parse is slower because it validates twice. C. safeParse only works on arrays, while parse only works on objects. D. There is no functional difference — they are aliases of the same function.
A. parse throws a ZodError on failure; safeParse always returns a { success, data | error } result object and never throws. — parse throws on invalid input, which is appropriate when failure should be an exception. safeParse never throws — it returns a discriminated result you branch on with if (result.success), which is usually preferable at boundaries where failure is expected and must be handled gracefully.
What is the main benefit of deriving a TypeScript type with z.infer<typeof Schema> instead of writing a separate interface? A. The type and the runtime validator can never drift apart, since both come from the same schema definition. B. It makes the bundle size smaller than a hand-written interface would. C. It allows the schema to skip validating optional fields entirely. D. It automatically generates React components for each field.
A. The type and the runtime validator can never drift apart, since both come from the same schema definition. — A hand-written interface and a hand-written validator can silently drift apart as the code evolves — someone updates one and forgets the other. Deriving the type from the schema with z.infer guarantees they always match, because there is only one source of truth.
You have a /api/products endpoint that should return an array of objects shaped like { id: number, name: string, priceCents: number, tags: string[] }. Write a Zod schema for a single product and for the array, then write a fetchProducts function that uses safeParse, logs a clear error on failure, and returns [] as a safe fallback instead of throwing.
const ProductSchema = z.object({ id: z.number(), name: z.string().min(1), priceCents: z.number().int().nonnegative(), tags: z.array(z.string()) }); const ProductListSchema = z.array(ProductSchema); async function fetchProducts() { const raw: unknown = await (await fetch('/api/products')).json(); const result = ProductListSchema.safeParse(raw); if (!result.success) { console.error('Invalid /api/products response', result.error.flatten()); return []; } return result.data; }
Start with z.object({...}) for one product, wrap it in z.array(...) for the list, call .safeParse(raw) on the parsed JSON, and branch on result.success before returning.