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”// @expect-error TS18046declare const input: string;
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.
catch clauses are the same boundary in disguise. With useUnknownInCatchVariables — part of strict, and therefore on by default since TypeScript 6.0 — the caught value is unknown, not any, so err.message must be earned with err instanceof Error first.
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:
declare const input: string;
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:
// @skip-verify zod is not a dependency of this course — shape shown for reference onlydeclare const input: string;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"]
Boundaries beyond JSON.parse
Section titled “Boundaries beyond JSON.parse”JSON.parse gets all the attention, but it’s one of many places typed code touches something the compiler didn’t check. Four more that come up in nearly every real codebase — each one typed honestly, but easy to skip past:
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
const port = process.env.PORT;const check1: Equal<typeof port, string | undefined> = true; // env vars might not be set
const saved = localStorage.getItem("theme");const check2: Equal<typeof saved, string | null> = true; // missing key -> null, not undefined
const params = new URLSearchParams("?page=2");const page = params.get("page");const check3: Equal<typeof page, string | null> = true; // missing param -> null too
// A minimal shape mirroring @types/express's Request<...> generic (ReqBody defaults to `any`)// named ExpressRequest -- plain "Request" would merge with the DOM lib's global Request interfaceinterface ExpressRequest<ReqBody = any> { body: ReqBody }declare const req: ExpressRequest;req.body.user.name.toUpperCase(); // compiles -- Express's req.body is `any` by default, same disease as JSON.parseprocess.env.X is string | undefined — Node has no way to know which environment variables actually exist at runtime. localStorage.getItem and URLSearchParams.get both return T | null (not undefined) for a missing key — a real, easy-to-miss difference if you write === undefined instead of == null. And req.body in the widely-used @types/express typings defaults its ReqBody type parameter to any, so it inherits every problem JSON.parse has unless a route handler narrows or validates it explicitly.
The classic bug this produces: reading process.env.PORT and using it as a number without checking that it parsed:
const port = Number(process.env.PORT); // process.env.PORT is string | undefinedconsole.log(`listening on port ${port}`); // compiles clean -- port might be NaNA type-checked env var read that quietly becomes NaN
const port = Number(process.env.PORT); // process.env.PORT is string | undefinedconsole.log(`listening on port ${port}`); // compiles clean -- port might be NaN Run it with PORT unset and it prints listening on port NaN — zero compile errors, one very real production bug. The fix is the same validate-at-the-edge shape as every other boundary in this lesson: a small hand-written guard that turns “maybe a string” into “definitely a valid port, or a thrown error”:
function requirePort(raw: string | undefined, fallback: number): number { if (raw === undefined) return fallback; const n = Number(raw); if (!Number.isInteger(n) || n <= 0) { throw new Error(`invalid PORT: ${JSON.stringify(raw)}`); } return n;}
const port = requirePort(process.env.PORT, 3000);console.log(`listening on port ${port}`); // port is a validated number, never NaNFor a real request body, the same shape scales up to a schema. The zod sketch from the section above is the general answer — a single schema is both the requirePort-style guard and the static type, for a shape far more complex than one number.
Reviewing a boundary in an AI-generated PR
Section titled “Reviewing a boundary in an AI-generated PR”Since a boundary bug is invisible to tsc — the types can be perfectly honest (string | undefined, any) while the code still forgets to check — reviewing generated code for boundary safety is a grep exercise, not a compiler one. Four searches catch most of it:
# 1. Every place raw external data entersgrep -rn "JSON.parse\|\.json()" src/
# 2. Every place a cast overrides the checkergrep -rn " as \| as any\b" src/
# 3. Every non-null assertion — is it actually provable?grep -rn "!;\|!\." src/
# 4. Every any — did it leak from somewhere upstream?grep -rn ": any\b" src/Each hit has to prove something before you approve it: a JSON.parse/.json() call must be immediately followed by a guard or schema, not used raw; an as cast must be justified by something the compiler genuinely can’t infer (not just “the error was annoying”); a ! must be provably non-null at that exact line, not merely “usually” non-null; and an any must be a deliberate, temporary boundary marker, not a value quietly flowing three functions deep. Generated code is often typed correctly and still fails every one of these — remember that a green tsc only proves internal consistency with the types written, not that any of these hits were handled safely.
unknown in catch: the instanceof/String recipe
Section titled “unknown in catch: the instanceof/String recipe”Since useUnknownInCatchVariables is on by default under strict, reaching into a caught value without narrowing it first is a compile error, not just a style nit:
// @expect-error TS18046async function loadConfig() { try { JSON.parse("{ invalid"); } catch (e) { console.error("config load failed: " + e.message); // e is unknown, not Error }}JavaScript lets you throw anything — a string, a plain object, undefined — so the compiler is right not to assume e has a .message. The standard recipe handles both the common case (a real Error) and the fallback (anything else), and is worth memorizing exactly as written:
async function loadConfig() { try { JSON.parse("{ invalid"); } catch (e) { const message = e instanceof Error ? e.message : String(e); console.error("config load failed: " + message); }}
loadConfig();Running it prints a real, useful SyntaxError message — config load failed: Expected property name or '}' in JSON at position 2 (line 1 column 3) — for the common case, and String(e) still produces something readable for the rare case where whatever was thrown isn’t an Error at all.
Two more edges: JSON files and dates
Section titled “Two more edges: JSON files and dates”JSON imports. Importing a .json file gives you a type derived from its contents — no schema needed, because the compiler can read the file. Under --module nodenext (TypeScript 5.7+) the import must carry the type: "json" attribute, and only the default import is allowed, exactly as Node requires:
// @skip-verify JSON module import needs resolveJsonModule and a json fileimport config from "./config.json" with { type: "json" };config.port; // typed from the file; named imports are an error(Verified separately, outside this harness: tsc --module nodenext --resolveJsonModule against a real config.json with { "port": 8080 } compiles clean once the access is config.port, not config.default.port.)
For hand-written config objects, satisfies keeps the literal types while checking the shape:
const routes = { home: "/", user: "/users/:id" } satisfies Record<string, `/${string}`>;routes.home; // still the literal "/" — not widened to stringDates. TypeScript ships types for the Temporal API in the esnext library (lib.esnext.temporal.d.ts — not yet folded into es2025, verified against the installed 7.0.2 lib files). When a boundary hands you an ISO timestamp, Temporal.Instant.from(iso) gives you an immutable, timezone-explicit value — a far better inner type than a mutable Date, and Node 26 is expected to enable Temporal by default.
const instant = Temporal.Instant.from("2026-09-22T00:00:00Z");instant.epochMilliseconds; // type-checksImmutability at the boundary
Section titled “Immutability at the boundary”Data you got from a boundary — a parsed response, a config object — is a natural place to want a guarantee: “nothing downstream can mutate this.” TypeScript gives you several tools here, and every one of them is shallower than it looks at first glance.
readonly on a property only protects that one binding, not whatever it points to:
interface Config { readonly name: string; limits: { max: number };}
function tweak(cfg: Config) { cfg.limits.max = 999; // ✅ compiles -- readonly only protects the "limits" binding itself, not what it points to}Readonly<T> has exactly the same shallowness, because it’s defined as { readonly [K in keyof T]: T[K] } — one level of mapping, no recursion into nested objects:
interface Config { limits: { max: number } }function tweak(cfg: Readonly<Config>) { cfg.limits.max = 999; // ✅ compiles -- Readonly<T> only makes top-level props readonly}Object.freeze is shallow at both levels — the runtime freeze only locks the object’s own top-level properties, and its return type (Readonly<T>) matches that exactly:
const cfg = Object.freeze({ name: "prod", limits: { max: 10 } });cfg.limits.max = 999; // type system allows it -- freeze's return type is only shallowly Readonly<T>console.log(cfg.limits.max); // 999 -- the runtime freeze didn't stop this eitheras const is the one that actually reaches every nesting level, because it’s a literal-inference instruction, not a mapped type — the compiler marks every property readonly as it infers the literal type, recursively:
// @expect-error TS2540const cfg = { name: "prod", limits: { max: 10 } } as const;cfg.limits.max = 999; // ❌ as const's readonly does reach nested objects, unlike Readonly<T> or Object.freeze's typeAnd a plain mutable array is never assignable from a readonly one — this is the one place immutability is enforced structurally, both ways:
// @expect-error TS4104function sortInPlace(arr: number[]): number[] { return arr.sort();}
const frozen: readonly number[] = [3, 1, 2];sortInPlace(frozen); // ❌ readonly number[] is not assignable to the mutable type number[]For real deep immutability, you write (or import) a recursive mapped type — a preview of the type-level-programming module:
// @expect-error TS2540type DeepReadonly<T> = T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
interface Config { name: string; limits: { max: number } }
function tweak(cfg: DeepReadonly<Config>) { cfg.limits.max = 999; // ❌ DeepReadonly reaches every nesting level, unlike Readonly<T>}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:
// @expect-error TS2345type 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.
Mental Model
Section titled “Mental Model”Every technique in this lesson is answering one question with varying honesty: “how sure is the compiler that this value is what it claims?” unknown is the compiler admitting total uncertainty and refusing to let you proceed without proof. process.env/localStorage/URLSearchParams are the compiler being honestly, precisely uncertain (| undefined, | null) rather than optimistic. any — on JSON.parse, on req.body — is the compiler being told to stop being uncertain, which is not the same as the uncertainty going away. And readonly/Readonly<T>/Object.freeze are a mirror image of the same idea: each one is honest about exactly how much of the shape it actually protects (one binding, one level, one level at runtime), and only as const or a hand-written DeepReadonly earn the stronger claim, because they’re the only ones that actually walk every level.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
Hand-written type guard (x is T) | No dependency, full control over the check | Tedious for real shapes, drifts from the type as the shape evolves, easy to write a guard that’s weaker than it claims |
| Schema library (zod-style) | One declaration is both validator and type — cannot drift | An added dependency; runtime cost of validation on every parse |
readonly / Readonly<T> | Zero runtime cost, catches top-level reassignment at compile time | Shallow — nested objects/arrays remain fully mutable |
Object.freeze | Real runtime enforcement, not just a compile-time promise | Shallow at runtime too, and throws only in strict-mode JS (silently no-ops otherwise) |
as const / hand-written DeepReadonly | Actually reaches every nesting level | as const only works on a literal you’re writing right now, not an existing typed value; DeepReadonly needs type-level programming to write well |
Common Misconceptions
Section titled “Common Misconceptions”- A
readonlyproperty orReadonly<T>makes the whole object immutable. Both are shallow — they protect the property binding one level down, not anything nested inside it. Object.freezefixes whatReadonly<T>’s type doesn’t. It doesn’t go any deeper at runtime thanReadonly<T>goes at compile time — freezing an object still lets you mutate its nested objects freely.req.body,process.env, andlocalStorage.getItemare “probably fine” because TypeScript would have flagged a real problem. Their types are honest (any,string | undefined,string | null) — the compiler already told you exactly how much it doesn’t know; the bug is in code that doesn’t act on that honesty.- A green
tscon generated code means the boundary was handled. Boundary bugs are runtime-only by nature (a missing check, not a type error), so a passing compile proves nothing about whetherJSON.parse,req.body, or a caught exception was actually validated.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated code that touches a boundary, check for:
process.env.Xused directly in arithmetic or passed to a function expecting a non-undefinedvalue, with no check or default.localStorage.getItem(...)orparams.get(...)compared with=== undefinedinstead of== null(or a real!== nullcheck) — both APIs returnnullfor a missing value, notundefined.- A caught exception’s
.message(or any property) accessed withoutinstanceof Errorfirst, or@ts-ignore/as anyused to silence the resultingTS18046instead of adding the real check. - Any of the four grep patterns above (
JSON.parse/.json(),as,!,: any) with no accompanying validation, guard, or clear justification in the same diff. - A “deep clone” or “deep freeze” helper that claims full immutability but is actually only one level deep — verify against a nested property, not just the top-level one.
🐛 Spot the bug
async function loadUserSettings() {try { return JSON.parse(localStorage.getItem("settings"));} catch (e) { console.error("failed to load settings: " + e.message); return {};}}Show the answer
Two boundary bugs stacked in four lines. First, localStorage.getItem("settings") returns string | null, and JSON.parse requires a string — if the key was never set, this throws TypeError: Cannot read properties of null (or, worse, if it were any-typed loosely enough to skip the check, JSON.parse(null) actually parses the string "null" into the value null, silently). Second, inside the catch, e is unknown under useUnknownInCatchVariables, so e.message doesn’t even compile without a cast — and if this snippet is inside a file with looser settings (or the cast was added to make it compile), a non-Error throw (which JSON.parse itself doesn’t produce, but a lot of other code paths do) would print undefined instead of a useful message. The fix is localStorage.getItem("settings") ?? "{}" before parsing, and e instanceof Error ? e.message : String(e) in the catch.