EduFlow

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.

  1. 1Start from unknown
  2. 2A parser, by hand
  3. 3Type predicates
  4. 4Assertion functions

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.

On the left, sources outside the program: fetch responses, JSON.parse, URL parameters and localStorage, all typed as unknown. They pass through parsers at the boundary, which either return an error or a typed Order. Inside the boundary, the core checkout code only sees trusted types. Outside: unknown res.json() JSON.parse URL params localStorage parseOrder() check, then brand err(…) Inside: trusted Order no casts, no checks
Untrusted data becomes typed data at one gate

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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
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; // OrderStatus

Assertion 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() and localStorage give you any; annotate their results as unknown so the compiler forces a check.
  • A parser takes unknown and 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

const data = await res.json(); What is the type of data, and what should you do about it?

Question 2 of 4

What is wrong with this predicate?

TypeScript
function isOrder(value: unknown): value is Order {
  return typeof value === 'object' && value !== null && 'id' in value;
}
Question 3 of 4

How does an assertion function differ from a type predicate?

Question 4 of 4

Where should a value from localStorage.getItem('cart') be parsed?

Practice

Code playground

Parse the order at the boundary

Replace the lying cast in parseOrder with a real parser, and give the helpers real types:

  1. isRecord and isOrderStatus become type predicates.
  2. parseOrder(input: unknown) returns ok(order) only when id is an integer, status is a known status, totalCents is a whole number, and couponCode is a string or null. Otherwise it returns err(message), where the message starts with the name of the bad field (for example status: …). The returned order must contain only those four fields.
  3. assertIsOrder becomes an assertion function that narrows its argument to Order and throws an Error whose message contains Invalid order when parsing fails.

No as casts in parseOrder.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T>(value: T): { ok: true; value: T } => ({ ok: true, value });
const err = <E>(error: E): { ok: false; error: E } => ({ ok: false, error });

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): boolean {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isOrderStatus(value: unknown): boolean {
  return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
}

// This "parser" trusts whatever the server sends.
function parseOrder(input: unknown): Result<Order, string> {
  return ok(input as Order);
}

function assertIsOrder(input: unknown): void {
  const parsed = parseOrder(input);
  if (!parsed.ok) throw new Error(`Invalid order: ${parsed.error}`);
}

// ---- Type tests (do not edit) ----
function typeTests(x: unknown, y: unknown) {
  if (isRecord(x)) {
    type T1 = Expect<Equal<typeof x, Record<string, unknown>>>;
  }
  if (isOrderStatus(x)) {
    type T2 = Expect<Equal<typeof x, OrderStatus>>;
  }
  assertIsOrder(y);
  type T3 = Expect<Equal<typeof y, Order>>;
}

const fromServer: unknown = JSON.parse('{"id":1042,"status":"refunded","totalCents":18900,"couponCode":null}');
const parsed = parseOrder(fromServer);
console.log(parsed.ok ? parsed.value : parsed.error);
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • A valid order is accepted
  • An unknown status is rejected with a message naming status
  • Wrong field types, arrays and null are rejected with messages naming the field
  • Unexpected fields are not copied into the parsed order
  • assertIsOrder throws ‘Invalid order…’ for bad data