Typing API responses you do not control
Here is the most important limitation in all of TypeScript, and the one beginners take longest to internalise: types are erased before your code runs, so they cannot check data that arrives at runtime. The JSON from an API, the body of a form, a row from a database — TypeScript has no idea what shape any of it actually is. It will happily believe whatever you tell it, and if you tell it wrong, you get exactly the runtime crash TypeScript was supposed to prevent. This lesson is how to handle the data you do not control, which is the boundary where typed code most often goes wrong.
The core problem: the compiler cannot see runtime data
Consider the code every developer writes:
interface User {
id: number;
name: string;
email: string;
}
const res = await fetch("/api/user/1");
const user: User = await res.json(); // DANGER — this is a lie, not a check
console.log(user.name.toUpperCase());
res.json() returns any (or Promise<any>), and annotating : User tells the compiler "trust me, it is
a User". But nothing checked that. If the API returns { id: 1, fullName: "Asha" } (no name), or
returns an error object, or the field is null, TypeScript said nothing — user.name is undefined and
.toUpperCase() crashes at runtime. The type annotation gave you a false sense of safety: it looks
checked, but the one place it matters most — the boundary with the outside world — is precisely where the
type is a guess.
This is not a flaw you can annotate your way out of. Types are compile-time only; the API responds at runtime. The only real fix is to check the data when it arrives.
The honest baseline: unknown, then validate
The first discipline: type incoming data as unknown, not as the shape you hope for. unknown forces you
to prove the shape before using it (the prefer-unknown lesson), which is exactly right at a boundary:
const data: unknown = await res.json(); // honest — we do NOT know the shape yet
function isUser(value: unknown): value is User {
return (
typeof value === "object" && value !== null &&
"id" in value && typeof (value as Record<string, unknown>).id === "number" &&
"name" in value && typeof (value as Record<string, unknown>).name === "string" &&
"email" in value && typeof (value as Record<string, unknown>).email === "string"
);
}
if (isUser(data)) {
console.log(data.name.toUpperCase()); // SAFE — checked at runtime, narrowed to User
} else {
throw new Error("API returned an unexpected shape");
}
This works — the type guard actually inspects the data — but you can feel the problem: a hand-written
guard for every API shape is tedious, easy to get subtly wrong, and drifts out of sync with the interface
it is supposed to mirror. For one or two shapes it is fine. For a real app with dozens of endpoints, you
want something better, which is why the ecosystem converged on schema validation.
The real-world answer: a runtime schema (Zod)
The standard solution is a schema-validation library — Zod is the common choice — that defines the shape once and gives you both the runtime check and the static type from that single definition:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>; // the static type, DERIVED from the schema
const data: unknown = await res.json();
const user = UserSchema.parse(data); // validates at RUNTIME; throws if the shape is wrong
console.log(user.name.toUpperCase()); // safe — parse guarantees it, and user is typed User
Two things make this the answer:
- One source of truth.
z.inferderives the TypeScript type from the schema, so the runtime check and the static type can never drift apart — change the schema and both update. Compare the hand-written guard, where theinterfaceand theisUserfunction are two things you must keep in sync by hand. - It actually checks at runtime.
.parse(data)inspects the real data and throws if it does not match — so past that line, the value genuinely is aUser, not merely asserted to be. Use.safeParsefor a non-throwing{ success, data | error }result when you want to handle the failure gracefully.
This is the pattern to reach for in real code. It is the honest version of res.json() as User — same
convenience at the call site, but with a real check behind it.
Model the failures, not just the success
External data does not only arrive in the wrong shape — the request itself can fail. A complete boundary handles both, and the Result pattern from earlier fits well:
async function fetchUser(id: number): Promise<User | { error: string }> {
try {
const res = await fetch(`/api/user/${id}`);
if (!res.ok) return { error: `HTTP ${res.status}` }; // the server said no
const data: unknown = await res.json();
const parsed = UserSchema.safeParse(data);
if (!parsed.success) return { error: "Unexpected response shape" }; // wrong shape
return parsed.data; // typed User, validated
} catch {
return { error: "Network error" }; // never even reached the server
}
}
Three distinct failures — the network never connected, the server returned an error status, the body was the wrong shape — and each is handled explicitly. A boundary that only types the happy response is incomplete; real APIs return errors, time out, and occasionally send garbage, and typed code should expect all three.
The rule for every boundary
Generalise beyond fetch, because the same problem appears everywhere untyped data enters your program:
JSON.parse(...)returnsany— validate the result.process.env.Xisstring | undefined— never the number or boolean you assume; parse and check it.- Form inputs, query parameters,
localStorage— all strings of unknown content until validated. - Database rows — the driver often types them loosely; the schema you think the table has may not be the schema it actually has.
The single rule: validate data at the boundary, once, and trust it thereafter. Inside your program, where you control every value, types are reliable and you can lean on them fully. At the edge, where data comes from outside, a type annotation is a hope, not a check — so turn the hope into a check with a guard or a schema, and only then let the type system take over. Getting this one habit right eliminates the most common class of "but TypeScript said it was fine" runtime crash.
Check your work
The core limitation. Types are erased before runtime, so TypeScript cannot check data that arrives at runtime — API responses, form input, DB rows.
Why const user: User = await res.json() is dangerous. res.json() is any; the annotation asserts
the shape without checking it — a false sense of safety at the very boundary that matters most.
The honest baseline. Type incoming data unknown, then prove the shape with a type guard before using
it.
Why a schema library (Zod) is the real answer. One definition yields both the runtime check
(.parse) and the static type (z.infer), so they cannot drift; and it actually validates at runtime
and throws on mismatch.
.parse versus .safeParse. .parse throws on a bad shape; .safeParse returns { success, data | error } for graceful handling.
The three failures a boundary must handle. Network failure (never reached the server), error status (server said no), wrong shape (bad body) — not just the happy response.
The general rule. Validate at the boundary once and trust it thereafter; also applies to JSON.parse,
process.env, form inputs, localStorage, and database rows.
Practice
- Write
const user: User = await res.json()against a mock that returns the wrong shape, and watch it crash at runtime with no compile error. - Rewrite it with
unknown+ a hand-written type guard; confirm the wrong shape is now caught. - Define a Zod schema, derive the type with
z.infer, and validate with.parse; break the data and watch.parsethrow. - Switch to
.safeParseand handle the failure branch gracefully instead of throwing. - Write a
fetchUserreturningUser | { error: string }that handles all three failure modes; exercise each. - Read
process.env.PORTand reason about its type (string | undefined); parse it to a number safely, handling the missing/invalid cases. - Take an existing
as SomeTypeonres.json()in code you can access and replace it with a validated parse.
Official documentation
- Zod — documentation — Schema definition,
parse/safeParse, andz.infer. - MDN — Response.json() — Why the parsed body is untyped.
- TypeScript — unknown type — The honest type for data you have not checked.
Next: the capstone — a typed weather API client, brief and design.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship