Typing APIs & Boundaries
Every bug enters at a boundary
Section titled “Every bug enters at a boundary”Inside your own code the compiler has your back. Bugs sneak in where typed code meets the untyped world: a network response, localStorage, a form, a third-party library, JSON.parse. At those edges TypeScript’s guarantees are only as good as what you do with the incoming data.
The first decision is which “I don’t know this type” you reach for: any or unknown.
any disables the checker; unknown keeps it
Section titled “any disables the checker; unknown keeps it”const a: any = JSON.parse(input);a.user.name.toUpperCase(); // compiles — and may explode at runtime
const u: unknown = JSON.parse(input);u.user.name; // ❌ Error: 'u' is of type 'unknown'.any opts out of type checking — every access is allowed and every guarantee is off. unknown is the honest top type: it holds any value, but forbids you from doing anything with it until you prove what it is. Prefer unknown at every boundary; treat any as a code smell you’re actively removing.
Note that JSON.parse is typed to return any — so its result silently poisons everything downstream unless you immediately assign it to unknown and validate.
Turning unknown into a trusted type
Section titled “Turning unknown into a trusted type”Narrowing unknown requires runtime checks the compiler understands. For simple shapes, a hand-written type guard:
type User = { id: number; name: string };
function isUser(v: unknown): v is User { return ( typeof v === "object" && v !== null && "id" in v && typeof (v as any).id === "number" && "name" in v && typeof (v as any).name === "string" );}
const data: unknown = JSON.parse(input);if (isUser(data)) { data.name.toUpperCase(); // ✅ data is User here}For anything real, hand-written guards get tedious and drift from the type. The standard answer is a schema/parser library (zod, valibot, and friends) where one declaration is both the runtime validator and the static type:
import { z } from "zod";
const User = z.object({ id: z.number(), name: z.string() });type User = z.infer<typeof User>; // static type derived from the schema
const data = User.parse(JSON.parse(input));// data is typed User AND was checked at runtime — throws if invalidOne source of truth, checked at runtime, inferred at compile time. This is the boundary done right.
flowchart LR raw["unknown (JSON, network, storage)"] -->|schema.parse / type guard| typed["trusted type (User)"] typed --> use["use freely no more checks needed"]
Branded types for nominal IDs
Section titled “Branded types for nominal IDs”Structural typing means a UserId and an OrderId that are both string are interchangeable — so you can pass one where the other is expected. When that matters, brand them with a phantom marker:
type UserId = string & { readonly __brand: "UserId" };
function getUser(id: UserId) { /* ... */ }
const raw = "u_123";getUser(raw); // ❌ plain string is not a UserIdgetUser(raw as UserId); // ✅ you deliberately asserted it at the boundaryThe brand exists only at compile time (it’s erased), costs nothing at runtime, and forces every UserId to be created deliberately — usually right after validation. It’s how you get nominal safety in a structural system.