open atlas
↑ Back to track
TypeScript type system, deep TS · 08 · 01

Typing APIs: a fetch type is a promise, not a proof

A type annotation on a fetch response is a promise about the server's shape, not a proof; `as User` checks nothing. Model responses as discriminated unions and Result types, and treat the network edge as where static types end and runtime validation begins.

TS Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

A pager goes off at 02:00. A TypeError: Cannot read properties of undefined (reading 'name') is spiking on the user profile page. You open the code and the line is user.profile.name — and user is typed User, with profile non-optional. The compiler swore this was safe. But the staging API shipped a change three hours ago: profile is now null for deleted accounts. TypeScript never knew. The annotation said User; the server sent something else; nobody checked. The type was a promise about the wire, and the wire broke it.

fetch hands you any, and as makes it worse

response.json() returns Promise<any>. That any is honest: at compile time TypeScript genuinely does not know what JSON the server will send. The moment you write a type, you are asserting knowledge the compiler cannot verify:

const res = await fetch("/api/user/42");
const user = await res.json(); // ^? any

// "I promise this is a User" — but nothing checks it
const typed = (await res.json()) as User; // ^? User

as User is a one-way assertion. It silences any and produces a value the rest of your program treats as fully-typed — but it performs zero runtime work. If the server sends { id: 42, profil: null } (note the typo, or a renamed field, or a null where you expected an object), typed.profile.name compiles, ships, and throws at runtime. The cast was a lie the type system had no way to catch.

Why this works

Why does TypeScript allow such an obviously unsound cast? Because as is the designed escape hatch for “I know something the compiler doesn’t.” It is appropriate when you truly have the knowledge — e.g. document.getElementById("x") as HTMLInputElement when you wrote the HTML. At the network boundary you do not have that knowledge: the server is a separate process, possibly a separate team, possibly a different deploy. That is exactly the situation as was never meant for.

as Response and the boundary of trust

The data is untrusted, and so is its shape. Picture the round trip: your typed call leaves, JSON bytes come back, and there is a fork in the road. One branch casts — fast, free, and unsound. The other validates — a runtime check that actually inspects the bytes and only then lets a typed value through.

The boundary is the single most important concept in this unit: it is the line where compile-time guarantees stop. Everything left of it (your call sites, your render code) is proven by the type checker. Everything right of it (the actual response) is runtime data the checker never saw. A cast pretends the line isn’t there. Validation is the only thing that earns the type.

Model the response as a discriminated union, not a happy-path shape

Even before validation, you get further by typing reality. Real APIs return errors, not just successes. A field that is “always there” on the happy path is undefined in the error path. Model both:

type ApiOk<T> = { ok: true; data: T };
type ApiErr = { ok: false; error: { code: string; message: string } };
type ApiResult<T> = ApiOk<T> | ApiErr;

declare function getUser(id: number): Promise<ApiResult<User>>;

const r = await getUser(42);
if (r.ok) {
  r.data; // ^? User  — narrowed: data exists only on the ok branch
} else {
  r.error.message; // ^? string  — error exists only here
  // r.data;  // Error: Property 'data' does not exist on type 'ApiErr'
}

The ok: true | false discriminant (unit 02) forces every caller to handle the failure branch — the compiler refuses to let them reach r.data without first proving r.ok. This is strictly better than a User | undefined return that gets !-ed away. It does not validate the bytes, but it makes the contract honest in the type system.

Result<T, E> pushes errors into values

The same idea, generalised: instead of throwing across the boundary, return a Result and make the caller deal with both arms. Errors become ordinary values the type checker tracks:

type Result<T, E = Error> =
  | { kind: "ok"; value: T }
  | { kind: "err"; error: E };

function parseJson<T>(text: string): Result<T, SyntaxError> {
  try {
    return { kind: "ok", value: JSON.parse(text) as T };
  } catch (e) {
    return { kind: "err", error: e as SyntaxError };
  }
}
// Note: the `as T` inside is STILL a lie — JSON.parse returns any.
// Result models the throw/no-throw fork honestly, but it does not
// verify the success value's shape. That gap is what lesson 2 closes.

Result makes control flow type-safe. It does not make the payload type-safe — the as T is the same boundary lie wearing a nicer coat.

Sharing types across client and server

When you find yourself copying a server interface into a frontend file by hand, ask yourself: what happens when the server adds a field next week? The cleanest way to make the annotation less of a lie is to stop hand-writing it. If client and server share one source of truth, the type can’t silently drift from the bytes:

  • Same monorepo / shared package — export the response type from one module both sides import. The next lesson’s zod schemas and lesson 3’s tRPC are two ways to do this; both derive the type from one definition.
  • OpenAPI-generated types — when the server publishes an OpenAPI spec, a codegen step turns it into client types (see the APIs track on OpenAPI). The generated User is mechanically tied to the documented contract, so it drifts only when the spec drifts — and a spec diff is reviewable. It still does not validate at runtime; a server bug that violates its own spec sails through. Generation narrows the drift gap; it does not close the trust gap.
Quiz

You write `const u = (await res.json()) as User`. The server changes `profile` from an object to `null`. What happens?

Order the steps

Order the journey of a fetched value from request to a trustworthy typed value:

  1. 1 Call fetch and await res.json() — the result is typed any
  2. 2 Reach the network boundary: compile-time guarantees stop, bytes are untrusted
  3. 3 Choose validate, not cast: run a runtime check that inspects the actual bytes
  4. 4 On success, narrow to the typed shape; on failure, handle the error branch explicitly
Recall before you leave
  1. 01
    Why is `(await res.json()) as User` described as a lie, and when is `as` legitimately fine?
  2. 02
    What is 'the network boundary' and why does it matter for typing?
  3. 03
    How do discriminated unions and Result<T,E> improve API typing, and what do they NOT fix?
Recap

You now treat a fetch annotation as an unverified promise, you can spot why as User is unsound at the boundary, and you model responses as discriminated unions and Result types so failure is part of the contract. But the contract is still only as honest as the bytes allow — the cast inside parseJson is the unclosed gap. Next, zod closes it: you define a schema once, derive the static type from it with z.infer, and .parse/.safeParse inspect the real bytes at runtime and narrow — turning the boundary promise into a checked proof from a single source of truth. Now when you see (await res.json()) as SomeType in a PR, you know exactly what to ask: where is the runtime check?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.