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:
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:
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.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.