Type Safety Boundaries
Date: 2026-09-28
TypeScript’s guarantees end wherever a type is asserted rather than proven — and there are more of those places than
any. Find them, validate once at each outer edge, and let the proven type flow inwards; everywhere else, treat a cast as a claim someone has to be right about.
A type safety boundary is any point where a value’s static type is supplied by assertion — a cast, an untyped source such as parsed JSON (JavaScript Object Notation), a compiler assumption — instead of being established by a check the compiler followed. Inside the boundaries the types are proven; at them, they’re promises. Why types can’t check data at all is in Type Systems and Type Checking in CI; this note is the full list of where it happens.
Where the guarantee leaks
LEAK WHAT THE COMPILER ASSUMES STRICT CATCHES IT?
──────────────────────────────────── ────────────────────────────────── ──────────────────
any (explicit, or from a library) nothing — checking is off no; lint rule
x as Order "trust me" no
x! (non-null assertion) "not null here" no
JSON.parse / res.json() returns any no
catch (e) e is any yes — unknown, 4.4+
arr[i], record[key] the element exists no — needs
noUncheckedIndexedAccess
user type predicate x is T the body is correct no
.d.ts declarations, @types/* the declaration matches the JS no
@ts-ignore / @ts-expect-error the error isn't real no
generic return get<T>(url) T is whatever the caller wrote no
localStorage, URLSearchParams, string | null — honest, but then
FormData, postMessage, env vars usually followed by `as` no
strict covers less of this than its name suggests. It gets catch variables and null checks right; it does nothing about casts, parsed JSON or index access. noUncheckedIndexedAccess is a separate opt-in flag that adds | undefined to every indexed read — noisy at first and worth it in data-handling code.
as is not conversion
The distinction that causes most of the bugs in this list:
const o = JSON.parse(body) as Order
o.total.toFixed(2) // compiles. the API sent total: "42.00" — a string.
// TypeError at runtime, far from the castas changes what the compiler believes and nothing about the value. The failure appears downstream of the lie, not at it — which is what makes these bugs expensive: the stack trace points at innocent code.
satisfies (4.9) is the checking alternative for literals you write yourself: it verifies the value matches a type without widening it to that type. It doesn’t help with data from outside — nothing static can.
Parse at the edge, trust inside
The fix is structural rather than a matter of care: validate once, at the outermost point, turning unknown into a proven type — then never cast again. This is often summarised as “parse, don’t validate”: the output of the check is the typed value, so there’s no way to use the data without having checked it.
import { z } from 'zod'
const Order = z.object({
id: z.string(),
total: z.coerce.number(), // accepts "42.00", yields 42
currency: z.enum(['GBP', 'EUR']),
lines: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })),
})
type Order = z.infer<typeof Order> // ← the type is DERIVED from the check,
// so the two can't drift apart
export async function getOrder(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${id}`)
return Order.parse(await res.json()) // throws here — at the boundary,
} // with the field that was wrongWhy this shape works:
- One schema, one type. Hand-written interfaces beside hand-written validators drift; deriving the type from the schema makes that impossible
- Failure moves to the edge. The error names the field and happens where the data arrived, not three components later
- Inside, no casts. Everything downstream receives a proven
Order
Zod is one runtime schema library among several (Valibot, ArkType; JSON Schema-based validators like Ajv); the pattern matters, not the library. The same schema at the edge of a form handler, a webhook receiver or an environment-variable loader closes the same hole — Environment Configuration.
Costs and judgement
- Validation costs runtime and bundle size. Parsing a 5,000-row response on every request is measurable. Validate at trust boundaries — third parties, user input, storage — and consider trusting your own typed backend if types are shared end to end
- Strictness has to match reality. Reject unknown enum values from a partner API and a new value they add takes down the page. Decide per field: fail, default, or pass through as
string asis sometimes right — narrowing a DOM (Document Object Model) query result you just created, or test fixtures. Grep for it; each one should have a reason a reviewer would acceptanyfrom dependencies is invisible without a lint rule for unsafe member access, since nothing in your code saysany— Linting
Related: Type Narrowing for how unknown becomes a type by ordinary checks, Schema Enforcement for the same idea applied to analytics events.