RizTech Academy logo
RizTech Academy
TypeScript in Real ProjectsLesson 6 of 630 min

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.infer derives 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 the interface and the isUser function 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 a User, not merely asserted to be. Use .safeParse for 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(...) returns any — validate the result.
  • process.env.X is string | 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

  1. 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.
  2. Rewrite it with unknown + a hand-written type guard; confirm the wrong shape is now caught.
  3. Define a Zod schema, derive the type with z.infer, and validate with .parse; break the data and watch .parse throw.
  4. Switch to .safeParse and handle the failure branch gracefully instead of throwing.
  5. Write a fetchUser returning User | { error: string } that handles all three failure modes; exercise each.
  6. Read process.env.PORT and reason about its type (string | undefined); parse it to a number safely, handling the missing/invalid cases.
  7. Take an existing as SomeType on res.json() in code you can access and replace it with a validated parse.

Official documentation

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