TypeScript and the runtime · 6 / 7
lesson 6

Typing fetch responses

as User is a lie. A wrapper that validates once is the move.

~ 16 min read·lesson 6 of 7
0 / 7

You hit the network. JSON comes back. You'd like to call it a User and move on. But something between your fetch and your component is going to bite you eventually — usually the day after a server change you didn't know about. This lesson is about putting one validator at the gate and never having to think about it again at the call sites.

What fetch hands you

The browser's fetch returns a Response object. To read JSON out of it, you call .json(). The signature for that method tells the truth.

raw-fetch.ts
const res = await fetch("/api/users/42");
const data = await res.json();
/* data has type any (TS strict: unknown — depends on lib version) */

Read the second line carefully. res.json() returns Promise<any> in most TypeScript setups. The reason is honest: the runtime can't know what JSON the server will send. The compiler refuses to make up a shape, so it gives you the broadest "I don't know" type it has.

any is the looser of the two — code can read any field off a value of type any and the compiler won't object. unknown is the stricter alternative; some configurations and helpers (@total-typescript/ts-reset, for example) widen .json() to return unknown so you can't accidentally use it before checking. Either way, the truth is the same: this value came from outside, and your code has not yet earned the right to call it a User.

Tip

If your project has the freedom, configure fetch response types to surface as unknown rather than any. The compiler errors that follow are not noise — they're a map of every gate you forgot to put a check on.

The cast is a lie

The temptation, written a thousand times in a thousand codebases:

the-cast.ts
type User = { id: string; name: string; email: string };

async function getUser(id: string): Promise<User> {
const res = await fetch("/api/users/" + id);
return (await res.json()) as User;   /* the lie */
}

The function's return type says User. Every caller will get full IntelliSense, full type safety, full confidence. None of it is real. The cast is a one-line note to the compiler that says "stop checking" — nothing else happens at runtime.

There's a worse variant: a generic helper that takes a type parameter and casts internally.

worse-cast.ts
async function api<T>(path: string): Promise<T> {
const res = await fetch(path);
return (await res.json()) as T;   /* a lie that scales */
}

const user = await api<User>("/api/users/42");
const order = await api<Order>("/api/orders/99");

This looks generic and reusable. It is also a way to spread the same lie across every endpoint in the app. Every caller gets a typed value that the compiler trusts and the runtime never checks. The first time the server changes a field name, every call site that touches that field becomes a runtime crash. There is no central place to fix it.

The shape of the answer is the same as everywhere else in this course: validate once, at the boundary, with a schema. The schema is the proof; the type is what the compiler infers from the schema. Nothing is asserted that wasn't checked.

check your understanding
What is wrong with function api<T>(path): Promise<T> that internally casts the response to T?

A typed fetch wrapper

The pattern: take a schema along with the URL, validate the JSON against the schema, return the validated value.

typed-fetch.ts
import { z } from "zod";

export async function fetchJson<T>(
path: string,
schema: z.ZodType<T>,
): Promise<T> {
const res = await fetch(path);
if (!res.ok) {
  throw new Error("HTTP " + res.status + " from " + path);
}
const raw: unknown = await res.json();
return schema.parse(raw);
}

schema is the real argument. Notice the type annotation — z.ZodType<T> ties the parameter T to "whatever this schema validates." When you call fetchJson(path, UserSchema), the inferred T is User, and the return type is Promise<User> — derived from the schema, not invented.

use-typed-fetch.ts
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});

const user = await fetchJson("/api/users/42", UserSchema);
/* user is User — and it actually is, because the schema checked */

The call site reads almost the same as before. The difference is invisible until it matters: when the server returns something unexpected, schema.parse throws a useful error. The function never returns a half-baked User that crashes deep in your component tree.

Two upgrades worth making early. First: split the validation into a non-throwing variant for cases where you have a fallback.

safe-fetch.ts
export async function safeFetchJson<T>(
path: string,
schema: z.ZodType<T>,
): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
const res = await fetch(path);
if (!res.ok) return { ok: false, error: "HTTP " + res.status };
const result = schema.safeParse(await res.json());
if (!result.success) return { ok: false, error: result.error.message };
return { ok: true, data: result.data };
}

The return type is a tagged union — same idea you saw with Zod's safeParse. Callers branch on ok, and TypeScript narrows to the right shape on each side. No try / catch at the call site for expected failures.

Second upgrade: bake the request shape (method, body, headers) in. The fetch wrapper is also where you centralize auth headers, CSRF tokens, base URLs.

full-wrapper.ts
type Options = { method?: string; body?: unknown; headers?: Record<string, string> };

export async function fetchJson<T>(
path: string,
schema: z.ZodType<T>,
options: Options = {},
): Promise<T> {
const res = await fetch(path, {
  method: options.method ?? "GET",
  headers: { "content-type": "application/json", ...(options.headers ?? {}) },
  body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!res.ok) throw new Error("HTTP " + res.status + " from " + path);
return schema.parse(await res.json());
}

Now the wrapper has one job per call site: pass the path and the schema. Auth, JSON encoding, error handling, validation — all in one place. When the API contract changes, the schema in one file changes; the rest follows.

Tip

The wrapper takes the schema as a value, not a type. That's what lets the runtime check happen. A wrapper that takes only a type parameter — fetchJson<User> — is the bad pattern this lesson is trying to replace.

check your understanding
You replace fetchJson<User>(path) with fetchJson(path, UserSchema). The runtime behavior changes how?

API drift, the slow killer

The bug worth fearing isn't the day-one mismatch. It's drift — the server team adds, removes, or renames a field, your client doesn't know, and the type still says everything is fine.

client User id, name, emailserver v2 id, name, contactEmaildriftschema.parse(raw) throws at the gate
Yesterday's contract and today's response don't have to match. Validation is the alarm.

Without validation: user.email becomes undefined, and the bug shows up wherever someone reads it — possibly in a totally unrelated part of the page. With validation: the wrapper throws the moment the response arrives, with a message that says "expected email, got contactEmail." You fix the schema in one file and ship.

The schema isn't a magic translator — it doesn't make the response work, it makes the failure loud and traceable. That's the difference between "users see a blank dashboard" and "you get a stack trace pointing at the API call."

Watch out

Don't make schemas too strict by accident. z.object by default ignores extra fields. .strict() rejects them. If the server ships a new optional field, strict-mode schemas will throw on every old client until you redeploy. Use loose by default; strict only when you really mean it.

check your understanding
The server renames email to contactEmail. Your client uses a Zod schema validated at the fetch boundary. What does the user see?

Errors at the boundary

A network call has more than one failure mode. The fetch wrapper has to handle each one, and treating them as a tagged result tends to be cleaner than a string of throws.

all-errors.ts
type ApiResult<T> =
| { kind: "ok"; data: T }
| { kind: "network"; error: Error }
| { kind: "http"; status: number }
| { kind: "shape"; issues: z.ZodIssue[] };

export async function callApi<T>(
path: string,
schema: z.ZodType<T>,
): Promise<ApiResult<T>> {
let res: Response;
try {
  res = await fetch(path);
} catch (err) {
  return { kind: "network", error: err as Error };
}
if (!res.ok) return { kind: "http", status: res.status };
const parsed = schema.safeParse(await res.json());
if (!parsed.success) return { kind: "shape", issues: parsed.error.issues };
return { kind: "ok", data: parsed.data };
}

The result is a discriminated union — four shapes, each with a kind tag. The caller switches on kind and handles whatever path matters: retry on network, show a 404 page on http when status is 404, log on shape so you find drift in production. This kind of typing is what makes error handling feel like a conscious decision instead of a try-catch reflex.

For a smaller app, the simpler safeFetchJson is usually enough. The fuller version is what scales as the surface grows.

check your understanding
Why split network errors, HTTP errors, and shape errors into different cases of a result rather than throwing them all the same way?
check your understanding
Which of these does validate the response shape at runtime?
check your understanding
Your team's wrapper uses schema.parse (throws). A page calls it on a non-critical sidebar widget. The schema is wrong on the staging server, breaking the entire page render. What's the lowest-friction fix?
← prevnext lesson →
KeepLearningcertificate
for completing
TypeScript and the runtime
0 of 7 read