It's easy to end up with a fully typed frontend, a fully typed backend, and an API between them that neither system actually verifies — at which point "type safety" is a local property of each side, not a property of the system. The boundary is where correctness either survives or quietly stops mattering.
Where the guarantee actually breaks
A typed client calling a typed server usually looks safe: the client has an interface describing the response shape, the server has a type describing what it returns, everyone compiles cleanly. None of that means the two definitions agree. They're two independent claims about the same wire format, hand-maintained (or generated from a spec that itself drifted), with nothing enforcing that they stay in sync as the API evolves. The server can start returning a field as string | null instead of string, ship cleanly because the server's own types were updated, and the client silently keeps assuming a string — a type error that no compiler on either side will ever catch, because neither side's compiler can see the other side's code.
Where real end-to-end safety comes from
The projects that actually get this right share one trait: a single source of truth for the contract, with both sides deriving from it instead of each side maintaining an independent, hand-synced copy. That takes a few different shapes in practice:
- Schema-first tooling (OpenAPI/JSON Schema with codegen) that generates both client and server types from one definition, so drift becomes a build failure instead of a runtime surprise.
- RPC frameworks that share TypeScript types directly across the boundary in a monorepo, so the client imports the actual server type rather than a hand-written approximation of it.
- Runtime validation at the boundary (parsing every request and response against a schema, not just casting) — because even a generated type only describes what the API is supposed to return, not what it actually returned in a given response, and a network boundary is exactly where "supposed to" and "actually did" diverge.
The part that's easy to skip
That last point matters more than it gets credit for. A TypeScript type is erased at compile time — it enforces nothing at runtime. A Response typed as User that actually contains malformed JSON, a missing field, or an upstream error payload will happily flow through the rest of the app as if it were a valid User, because the type annotation was never actually checked against the real bytes on the wire. Real end-to-end safety needs a runtime check at the seam, not just a compile-time promise on either side of it.
The takeaway
"We use TypeScript on the frontend and the backend" describes two typed islands, not one type-safe system. The types only become a system-level guarantee once something enforces that both sides agree — at the boundary, not before it and not after it.