Tags: web-dev concept

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 cast

as 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 wrong

Why 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
  • as is 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 accept
  • any from dependencies is invisible without a lint rule for unsafe member access, since nothing in your code says any — Linting

Related: Type Narrowing for how unknown becomes a type by ordinary checks, Schema Enforcement for the same idea applied to analytics events.