Validating Data at the Boundary
Anything crossing into your program is unknown until proven otherwise. Parsing over casting, schema validation, and why a type assertion on API data is a lie with a compile-time blessing.
Anything crossing into your program is unknown until proven otherwise. Parsing over casting, schema validation, and why a type assertion on API data is a lie with a compile-time blessing.
The line is one everybody writes:
const photo: Photo = await response.json();The checker is satisfied. Nothing verified anything. If the
service renamed size to sizeBytes last Tuesday, you now have
a Photo whose size is undefined, the arithmetic downstream
produces NaN, and the failure appears four functions away in
code that looks correct.
This is the most important limitation of the type system, and this lesson is about the boundary where it applies. By the end you will know what a type annotation actually claims, how to turn unverified data into a type you can trust, and where to put that check so nothing downstream repeats it.
Types are erased before the program runs. They describe the code you wrote; they cannot describe data that arrives while it runs.
Four places produce unverified data:
A network response
response.json() is any, so whatever you write after it is
accepted without question.
A file you parsed
Text on disk, written by an earlier version of your program or by a person.
A form, or anything a user typed
Strings, always, including the ones you think of as numbers.
localStorage, a database, a queue
Data that survived a deploy, and may predate the shape you now expect.
The dangerous part is that TypeScript believes you:
const config = JSON.parse(text) as Config;
const photo = data as Photo;
const id: string = params.id;Each is an assertion, not a check. as in particular performs
no work at all — it is you overruling the checker, and the
checker has no way to know you were wrong.
Bad — asserting the shape.
async function loadPhoto(id: string): Promise<Photo> {
const response = await fetch(`/photos/${id}`);
return (await response.json()) as Photo;
}Good — checking it.
import { z } from "zod";
const PhotoSchema = z.object({
id: z.string(),
name: z.string(),
size: z.number().int().positive(),
takenAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
});
type Photo = z.infer<typeof PhotoSchema>;
async function loadPhoto(id: string): Promise<Photo> {
const response = await fetch(`/photos/${id}`);
if (!response.ok) {
throw new ApiError(`photo ${id}: ${response.status}`);
}
return PhotoSchema.parse(await response.json());
}The first version fails silently and late. A missing field is
undefined inside a value the checker calls a Photo, so the
error surfaces wherever that field is finally used — a
formatting function, a chart, a database write — with a message
about the symptom rather than the cause.
The second fails immediately, at the boundary, with a message naming the field and what was wrong with it:
ZodError: [
{ "path": ["size"], "message": "Expected number, received undefined" }
]Note z.infer: the type is derived from the schema, so
there is one definition rather than two that can drift. That is
the single most valuable property of this approach — a
hand-written type beside a hand-written validator eventually
disagree, and nothing reports it.
One check, at the edge, and everything inside trusts the type:
Network, files, forms, storage
Shapes you hope for.
Validate
The only place that can reject.
Your code
Types that are true. No defensive checks anywhere.
Not this:
function caption(photo: Photo): string {
if (!photo.name) return "Untitled"; // defensive
...
}
function resize(photo: Photo): Photo {
if (typeof photo.size !== "number") { ... } // defensive again
}Those checks exist because nobody trusts the type, and they are in every function forever. Validating once removes all of them — and each one you leave in is a place a reader cannot tell whether the guarantee holds.
The boundary is also where you convert to the shape your program wants, rather than the shape the wire happens to use:
const ApiPhotoSchema = z.object({
photo_id: z.string(),
file_name: z.string(),
size_bytes: z.number(),
taken_at: z.string().datetime(),
});
const PhotoSchema = ApiPhotoSchema.transform((raw) => ({
id: raw.photo_id,
name: raw.file_name,
size: raw.size_bytes,
takenAt: new Date(raw.taken_at),
}));Now snake_case, string dates and awkward field names exist in exactly one file. Renaming a field upstream is a one-line change.
parse throws. safeParse returns a result, which is the
Result type from the previous lesson:
const result = PhotoSchema.safeParse(input);
if (!result.success) {
return {
ok: false,
errors: result.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
};
}
return { ok: true, value: result.data };Use parse when a failure means a bug or a broken dependency —
you want it to throw. Use safeParse when a human needs to be
told which field to fix, which is every form.
Report every problem at once. A form that reveals one error per submission is four round trips for four mistakes.
The library is doing something you could write, and seeing it once makes the trade obvious:
function parsePhoto(value: unknown): Photo {
if (typeof value !== "object" || value === null) {
throw new ValidationError("expected an object");
}
const raw = value as Record<string, unknown>;
if (typeof raw.id !== "string") {
throw new ValidationError(`id: expected string, got ${typeof raw.id}`);
}
if (typeof raw.name !== "string") {
throw new ValidationError(`name: expected string`);
}
if (typeof raw.size !== "number" || !Number.isFinite(raw.size)) {
throw new ValidationError(`size: expected a finite number`);
}
return { id: raw.id, name: raw.name, size: raw.size };
}Correct, and roughly seven lines per field. The single as is
contained — it claims only that this is an object with unknown
values, and every field is then checked individually.
For one small shape this is fine and adds no dependency. For a
dozen shapes with nesting and arrays it is the same code
repeated, and the hand-written Photo type beside it is a
second definition waiting to drift.
Always: anything crossing into your program from outside. Network responses, request bodies, query parameters, form input, files, environment variables, message payloads, anything from storage.
Environment variables deserve a mention, because they are data from outside that everyone treats as configuration:
const EnvSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().int().default(3000),
LOG_LEVEL: z.enum(["debug", "info", "warn"]).default("info"),
});
export const env = EnvSchema.parse(process.env);Every value is a string; coerce converts and validates
together. Doing this at startup means a misconfigured deploy
fails immediately rather than at 2am when some code path first
reads the variable.
Do not validate what you constructed yourself. A value that came from your own code and has not left the process is already whatever the checker says it is, and re-validating adds cost and noise.
Trusted internal services are still a boundary, though the threat model differs. They deploy independently of you, so their shape can change without your build failing — which is the same failure as an untrusted one, minus the malice.
THE LIMITATION
types are erased; they describe your CODE, not runtime data
const p: Photo = await res.json(); a claim, not a check
`as` performs no work at all
WHERE DATA ARRIVES UNVERIFIED
network responses request bodies query parameters
forms files environment variables storage queues
PARSE, DO NOT ASSERT
const Schema = z.object({ ... });
type Photo = z.infer<typeof Schema>; ONE definition
Schema.parse(input) throws
Schema.safeParse(input) a Result
parse when failure means a bug or a broken dependency
safeParse when a human must be told which field to fix
report EVERY problem at once
THE BOUNDARY
validate once at the edge; everything inside trusts the type
defensive checks in every function mean nobody trusts it
transform to YOUR shape at the same point, so snake_case and
string dates exist in one file
SECURITY
.strict() rejects unknown keys - stops unintended fields
reaching an update
bound strings and arrays - stop a request allocating a gigabyte
validation runs BEFORE your code; nothing later is reliable
BY HAND, WHEN THE SHAPE IS SMALL
reject non-objects and null
one contained `as Record<string, unknown>`
check each field individually
~7 lines per field, and a second type definition to keep in sync
ENVIRONMENT VARIABLES ARE DATA FROM OUTSIDE
validate at startup, so a bad deploy fails at deploy time
DO NOT VALIDATE
values you constructed yourself and never sent anywhere
internal services ARE a boundary - they deploy without youYou now know the one thing the type system cannot do, and the single line that fixes it: derive the type from a schema and parse at the edge. That removes an entire class of bug where a value is confidently the wrong shape and the failure appears somewhere unrelated.
Next is Async Patterns and Concurrency, which returns to promises with production concerns — running work together without overwhelming a service, cancelling what is no longer needed, and the loop that turns concurrent work into sequential work by accident.
Before you move on, find an as SomeType on data from outside
in your code and replace it with a schema. Then feed it a
response with one field missing and read the error. The
difference between that message and undefined is not a function four functions later is the whole argument.