TypeScript path · In a real project · Topic 11 of 11

Validating data at the boundaries

Not started

TypeScript checks the code you wrote against the types you wrote. It cannot check data that arrives while the program runs: an HTTP response, a form submission, a query string, process.env, localStorage, a postMessage event. JSON.parse and response.json() return any, and a cast such as as User only tells the checker what to believe. If the data doesn't match, the program carries on with wrong values until something breaks, usually somewhere far from the cause.

Why it matters: An API response typed with `as User` is a guess written down as a fact. When the API changes, the error shows up three functions later, far from its cause.

  1. 1The boundary is where types stop

    JSON from a server, form fields, URL parameters, environment variables, storage and messages from other windows all arrive at runtime, after the checker has finished.

  2. 2A cast is not a check

    `await res.json() as User` changes what the checker believes, not what the data is. If a field is missing or has the wrong type, nothing notices until it is used.

  3. 3Parse once, at the edge

    Treat incoming data as `unknown`, check it in one function that returns a typed value or an error, and let the rest of the code trust the type.

1

How it works

The explanation, with examples you can run.

What a cast hides

This is what typed code like const product = (await res.json()) as Product does at runtime once the API changes price from a number to a string. The types are gone; only the JavaScript is left:

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

It prints 9.502 and string. No error at the boundary, no error at the addition, and a wrong price on the page. The fix is to check the shape where the data enters.

A hand-written check

type Product = { id: string; title: string; price: number } function parseProduct(data: unknown): Product {  if (typeof data !== "object" || data === null) throw new Error("expected an object")  const { id, title, price } = data as Record<string, unknown>  if (typeof id !== "string") throw new Error("id must be a string")  if (typeof title !== "string") throw new Error("title must be a string")  if (typeof price !== "number" || !Number.isFinite(price)) throw new Error("price must be a number")  return { id, title, price }}

The input is unknown, so the checker forces every field to be tested before it can be returned as a Product. The rest of the program receives a value that really is one. It runs the same in the browser:

⌘/Ctrl + Enter
OutputNot run yet

Press Run to see what your code prints, in order.

Runs in this browser, in an isolated frame with no access to this page. console.log, info, warn and error are shown; code runs as a classic script, so use an async function for await.

Returning a new object with only the known fields also drops anything unexpected the server sent along.

Schemas: one source of truth

A hand-written check and its type are two things to keep in step. Schema libraries (Zod, Valibot, ArkType and others) let you write the runtime check once and derive the static type from it:

import { z } from "zod" const Product = z.object({  id: z.string(),  title: z.string(),  price: z.number(),})type Product = z.infer<typeof Product> // { id: string; title: string; price: number } async function loadProduct(id: string): Promise<Product> {  const res = await fetch(`/api/products/${id}`)  return Product.parse(await res.json()) // throws with a description of every mismatch}

safeParse returns a result object instead of throwing, which suits form input where errors are expected.

Where to put the checks

  • Every place data crosses into your program: API clients, route handlers, message listeners, config loading at startup (fail fast on a missing environment variable).
  • Once, at the edge. Inside the program, trust the types; repeating checks everywhere makes code noisy and still misses the one place that matters.
  • On the server too. A typed client does not protect an API: anyone can send it any bytes.
2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.An API starts returning `price` as the string "9.50". Code typed with `as Product` adds a 2.00 shipping fee. What happens?
  2. 2.Where should you validate the shape of an API response?
  3. 3.What does a schema library such as Zod add over a hand-written type guard?

0 of 3 right so far. Get all three to finish this topic.