Section 4 · Patterns for Real Codebases
Parsing Untrusted Data at the Boundary
20 min4 min readCode playground
Lesson at a glance
Treat everything from outside your program as unknown, turn it into trusted domain types with parsers, type predicates and assertion functions, and keep the casts out of your core code.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
In Designing a Typed API Client, the client ended with return result as Routes[R]['output']. That cast was a promise about the server. This lesson is about keeping that promise honest.
Every program has a boundary: the places where data comes from somewhere the compiler cannot see. HTTP responses, JSON.parse, URL parameters, localStorage, form fields, messages from other windows, environment variables. Inside the boundary, types describe values your own code created. At the boundary, types are just hopes. The pattern for handling it has a well-known slogan: parse, don’t validate. Turn untyped data into typed data once, at the edge, and let the rest of the program trust the types.
Start from unknown
JSON.parse returns any, and so does Response.json(). any is contagious: assign it to a variable typed Order and TypeScript believes you. The first step is to refuse that gift:
const raw: unknown = await res.json();
raw.status;
// Error: 'raw' is of type 'unknown'.unknown is the honest type for data you have not checked. You can pass it around, but you cannot use it until you narrow it. Narrowing is what a parser does.
A parser, by hand
A parser takes unknown and returns either a typed value or a description of what was wrong. With the Result type from the last lesson:
const STATUSES = ['processing', 'shipped', 'delivered', 'cancelled', 'returned'] as const;
type OrderStatus = (typeof STATUSES)[number];
type Order = { id: number; status: OrderStatus; totalCents: number; couponCode: string | null };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isOrderStatus(value: unknown): value is OrderStatus {
return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
}
function parseOrder(input: unknown): Result<Order, string> {
if (!isRecord(input)) return err('order: expected an object');
const { id, status, totalCents, couponCode } = input;
if (typeof id !== 'number' || !Number.isInteger(id)) return err('id: expected an integer');
if (!isOrderStatus(status)) return err(`status: unknown value ${JSON.stringify(status)}`);
if (typeof totalCents !== 'number') return err('totalCents: expected a number');
if (couponCode !== null && typeof couponCode !== 'string') return err('couponCode: expected a string or null');
return ok({ id, status, totalCents, couponCode });
}Look at the final line: there is no cast. Each check narrowed one variable, and by the end TypeScript can verify that the object literal matches Order. If the type gains a field and the parser forgets it, the ok(…) line fails to compile. This is also where you would brand values, calling toOrderId(id) from Branded Types and Exhaustive Checks.
Closing the gap in the API client
Back in the client, the fix for that final cast is a table of parsers, one per route, checked against the route map with a mapped type and satisfies:
const parsers = {
'GET /orders/:id': parseOrder,
'GET /orders': parseOrderPage,
'POST /orders/:id/cancel': parseCancelledOrder,
} satisfies { [R in keyof Routes]: (input: unknown) => Result<Routes[R]['output'], string> };Forget a route and satisfies reports the missing key; return the wrong type from a parser and it reports that too. Inside call, the response now goes through parsers[route] before it is returned. TypeScript cannot connect the generic R to the matching row of the table, so one annotation remains there, but it now sits on data that has actually been checked: the client’s output types are earned instead of asserted.
Type predicates
isRecord and isOrderStatus are type predicates: functions whose return type is value is T. When one returns true, TypeScript narrows the argument at the call site. Since TypeScript 5.5, simple ones like (x) => x !== null are inferred, as you saw in section 1, but anything with real logic needs the annotation.
Assertion functions
Sometimes you want to narrow and stop if the data is bad, without an if at every call site. An assertion function returns nothing and throws on failure, and its asserts return type narrows everything after the call:
function assertIsOrder(input: unknown): asserts input is Order {
const parsed = parseOrder(input);
if (!parsed.ok) throw new Error(`Invalid order: ${parsed.error}`);
}
const raw: unknown = JSON.parse(cachedJson);
assertIsOrder(raw);
raw.status; // OrderStatusAssertion functions fit places where bad data is a bug rather than an expected case: test helpers, data your own server rendered into the page, invariants. For HTTP responses, prefer a parser that returns a Result, so a changed API produces a handled error rather than a crash. One quirk: an assertion function must be called through a name with an explicit type, so const assertIsOrder = (x: unknown): asserts x is Order => … needs a type annotation on the const itself, or TypeScript reports “Assertions require every name in the call target to be declared with an explicit type annotation”.
In the exercise you will write Cartwheel’s order parser, a predicate and an assertion function, and replace a cast that has been lying since day one. Next lesson builds typed event emitters and builders on top of everything in this section.
Key takeaways
JSON.parse,response.json()andlocalStoragegive youany; annotate their results asunknownso the compiler forces a check.- A parser takes
unknownand returns a typed value or an error, so the rest of the code only ever sees data that has been checked. - A type predicate (
value is T) narrows at the call site, but TypeScript trusts its body, so a wrong predicate is an unchecked cast. - An assertion function (
asserts value is T) narrows everything after the call and must throw when the check fails. - Schema libraries declare the check once and derive the type from it, which removes the risk of a parser and a type drifting apart.