Lesson 25 of 28
Validating at the API Boundary
Types erase at runtime, so a fetch or localStorage value isn't guaranteed to match its declared shape. Validate once at the boundary with Zod or Valibot, then trust the parsed type.
Everything in this track so far has been about telling the compiler
what shape a value has, so that the compiler can tell you when you
use it wrong. That contract works beautifully — right up to the point a
value enters your program from somewhere the compiler never saw. A
fetch() response body, a parsed URL parameter, a value read out of
localStorage, an env var, a WebSocket message, a file on disk: none
of these came from your typed source code. They're bytes that your
program hopes match some shape.
A TypeScript type on the receiving variable is a claim about what
that data should look like. It is not a check. A response.json() as User tells the type checker to be quiet; it does not tell the server
to actually return a User. The fix is to validate at the boundary
itself, once, with a tool designed for the job.
Why as is not a check
A quick, concrete demonstration of the thing to never rely on:
type User = { name: string; age: number };
const res = await fetch("/api/user");
const user = (await res.json()) as User;
console.log(user.name.toUpperCase()); // compiles; may crash.If the server returned { error: "unauthorized" }, the API returned a
500-page HTML error, or someone renamed the field to user_name on
one deployment, the assignment still succeeds. The compiler trusts your
cast; TypeScript's type system doesn't exist at runtime to check it.
The crash shows up later and somewhere else — user.name is undefined
three function calls deep, or .map is not a function inside a React
component — far enough from the boundary that the real problem is hard
to trace.
This is not a TypeScript bug. It's an honest consequence of the design: types are erased so your program is still just JavaScript at runtime. The job that has to be done somewhere is "check this unknown thing against the shape I expect, now, at the boundary, and refuse it if it doesn't match."
The one-schema pattern
A runtime validator library (Zod, Valibot, Arktype are the common names) lets you describe the expected shape once, and get back two things from the same description:
- An object you can execute against an unknown value at runtime, which either returns a typed, validated result or produces a structured error.
- A TypeScript type, inferred from the same description, that you can use everywhere else in your code.
import { z } from "zod";
const User = z.object({
name: z.string(),
age: z.number().int().nonnegative(),
email: z.string().email().optional(),
});
type User = z.infer<typeof User>;
const res = await fetch("/api/user");
const user = User.parse(await res.json());
// user is now typed as User and guaranteed to actually match at runtime.Two things worth noticing about the shape of this code:
- The schema is the type.
z.infer<typeof User>gives you the compile-timeUserfrom the same object that does the runtime check. You never write the shape twice. - The failure surfaces at the boundary. If the response doesn't
match,
.parsethrows a structuredZodErrorwith the exact path that failed — right where the bad data entered, not three function calls later.
Valibot's API is slightly different (v.parse(User, data) rather than
User.parse(data)) and its bundle is smaller; Arktype's is different
again. The pattern — describe once, use both ways — is the same
regardless of which library you reach for.
Where to put the boundary, and where not to
A runtime validator buys you real safety. It is also real code — schemas take bytes, parsing takes microseconds, and validating every internal function call is wasteful theatre.
The useful rule is: validate exactly where untyped data enters your program, and trust the types everywhere inside.
Boundaries worth validating:
- HTTP responses from your own backend and from any third party. The other side can change, or deploy, or misbehave.
- HTTP request bodies on the server side — every request from a browser is untrusted input.
- URL parameters and form values. A user can type anything into a URL, including things your UI has no way to produce.
localStorage/sessionStoragereads. The stored value was written by an older version of your code with a different shape, almost certainly, eventually.- Environment variables.
process.env.PORTis a string or undefined, no matter what your Deployment YAML meant. - WebSocket messages, postMessage payloads, file contents. Anything that crosses a process or network boundary.
Boundaries not worth validating:
- The return value of a function you wrote, in the same file, with a typed signature. The compiler already checked it.
- Pure utility functions whose inputs are always other typed TS values.
- The hot path inside a tight render loop, where you've already validated the data at the point it was loaded.
The simplest way to make this concrete is a thin "entry" layer whose only job is to validate incoming data once and pass the parsed, typed result to the rest of the program:
export async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`fetchUser failed: ${res.status}`);
return User.parse(await res.json());
}Everything downstream of fetchUser sees a real User, and the
single place you have to look if the wire format changes is the file
the schema lives in.
Standard Schema: validator-agnostic APIs
A recent, useful development is that the major validator libraries now
implement a shared shape called Standard Schema — a small
interface (a ~standard property) that a schema object exposes,
describing its input and output types and offering a common
validate() method.
The practical effect: a library — a form library, a typed-endpoint framework, an SDK that accepts "give me a schema for the response shape" — can accept a Standard Schema instead of hard-coding a dependency on Zod or Valibot. Users of that library can then bring their own validator.
import { z } from "zod";
import * as v from "valibot";
// Both of these can be handed to the same Standard-Schema-aware library:
const ZodUser = z.object({ name: z.string() });
const ValibotUser = v.object({ name: v.string() });You don't have to care which library is downstream using which validator, and library authors stop having to pick a side. Knowing the name is enough; the details live in the Standard Schema repo for when you need them.
The whole pattern in one paragraph
Types are erased at runtime, so any value entering your program from outside its own typed source — network, storage, env, IPC, files — needs a real runtime check, not a cast. A validator library (Zod, Valibot, Arktype) lets you describe the expected shape once and get back both a runtime parser and the TypeScript type. Validate at the boundary, let the rest of the program trust the types. Standard Schema lets a library accept any of those validators without picking sides.
What to remember
response.json() as Useris a cast, not a check. At runtime, the value is whatever came back, and a mismatch surfaces far from the boundary.- A runtime validator (Zod, Valibot, Arktype) describes the shape once; you get the runtime check and the TypeScript type from the same object.
- Validate exactly where untyped data enters the program: HTTP in both directions, URL and form values,
localStorage, env vars, WebSocket and postMessage payloads, file contents. - Do not validate values that have never left typed TypeScript — internal function calls, pure helpers, hot inner loops that already validated their input upstream.
- A thin "entry" layer (one file per external source) that validates and returns the parsed type keeps the rest of the codebase simple and gives the schema one place to live.
- Standard Schema is the shared interface that lets multiple validators plug into the same library APIs — worth knowing by name so you can take advantage of it when picking tools.
Check yourself
4 questions · pass 3/4 to unlock The Native TypeScript Compiler
1.What does
response.json() as Useractually guarantee at runtime?2.What does a runtime validator (Zod, Valibot, or the shared Standard Schema interface) give you that a
type User = {...}declaration does not?3.Which of these is a system boundary where runtime validation genuinely earns its cost?
4.What is 'Standard Schema' and why does it matter to code that uses a runtime validator?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.