Course outline · 0% complete

0/26 lessons0%

Course overview →

Typing API Data

lesson 7-3 · ~13 min · 23/26

The boundary problem

Everything so far checks code you wrote. But data crossing into your program is typed by nobody: a web API response sent by another server (lesson 2-3), a file on disk, raw user input. JSON.parse returns any by design, and lesson 2-3 warned you where any leads.

The professional pattern has three steps:

  1. Declare an interface for the shape you expect
  2. Hold the parsed value as unknown, not any
  3. Write a checking function that proves the shape at runtime, then use the data fully typed

Step 3 uses a type predicate, a return type of the form value is ApiUser. It tells the compiler that when this function returns true, the argument should be treated as that type from there on. It is narrowing (Unit 4) that you define yourself.

JSON textunknown shapeisApiUser(data)runtime checkApiUserfully typedfalse → reject the payload
Data enters as unknown, passes a runtime check, and only then flows onward with a real type.

as: the assertion inside the checker

One new keyword appears in the checking function below: value as { id?: unknown }. The as keyword is a type assertion, telling the compiler to treat this value as this type, with no runtime check and no conversion. The compiler simply believes you.

That makes as dangerous as a general tool. Assert the wrong type and you have re-created the any problem with extra steps, a claim the running program can still violate.

The legitimate, narrow use is exactly what the checker does. After proving value is a non-null object, it asserts a shape whose properties are all typed unknown, so nothing becomes trusted, and each property must still pass its own typeof test before use. The assertion claims no more than "an object whose properties I am about to check".

That is the rule for as in general: use it only when you know something the compiler cannot, and keep the claim as weak as possible.

A checked API payload, end to end

This puts all three steps together: an interface for the expected shape, an unknown holding the parsed value, and a type predicate that proves the shape before use.

interface ApiUser {
  id: number;
  name: string;
  email: string;
}

const raw = '{"id": 1, "name": "Ada", "email": "ada@example.com"}';
const data: unknown = JSON.parse(raw);

function isApiUser(value: unknown): value is ApiUser {
  if (typeof value !== "object" || value === null) {
    return false;
  }
  const v = value as { id?: unknown; name?: unknown; email?: unknown };
  return typeof v.id === "number" && typeof v.name === "string" && typeof v.email === "string";
}

if (isApiUser(data)) {
  console.log(data.name + " <" + data.email + ">");
} else {
  console.log("bad payload");
}

Output

Ada <ada@example.com>

Following the narrowing

  • Inside the if, data is an ApiUser and every property is typed. Outside it, data is still unknown, so the else branch cannot accidentally read data.name.
  • The as keyword appears once, and only to inspect properties of a value already proven to be a non-null object. Every property it exposes is typed unknown, so nothing is trusted yet.
  • Change "id": 1 to "id": "1" in the raw string and the check returns false, printing bad payload. The bad data is caught at the boundary rather than surfacing as a strange result three functions later.

Why the parsed value starts as unknown

Typing the JSON.parse result as ApiUser directly would be a promise the server might break, while unknown forces an actual runtime check first.

The core point is that annotations are compile-time claims. They do not inspect real data, and they cannot. Writing const data: ApiUser = JSON.parse(raw) compiles without complaint no matter what the server actually sends, because there is nothing for the compiler to verify against.

The failure mode that follows is the expensive kind. A server bug or an API change ships, your code reads data.name.toUpperCase() on a value that is missing name, and the crash happens deep inside your program, far from the boundary where the bad data entered.

unknown makes the compiler refuse to touch the value until a runtime check proves the shape. The check has to exist, and it runs at the edge of the program, which is exactly the boundary discipline production apps use.

isProduct: the same pattern with two properties

Product has a name and a price, and isProduct proves a parsed value matches before anything reads from it.

interface Product {
  name: string;
  price: number;
}

const raw = '{"name": "Keyboard", "price": 49}';
const data: unknown = JSON.parse(raw);

function isProduct(value: unknown): value is Product {
  if (typeof value !== "object" || value === null) {
    return false;
  }
  const v = value as { name?: unknown; price?: unknown };
  return typeof v.name === "string" && typeof v.price === "number";
}

if (isProduct(data)) {
  console.log(data.name + " costs $" + data.price);
} else {
  console.log("bad payload");
}

Output

Keyboard costs $49

Reading the checker

  • It mirrors isApiUser from the previous example, with two properties instead of three. The structure is a template you will reuse for every shape that crosses a boundary.
  • The first guard rejects non-objects and null. The null case needs its own test because typeof null is "object", a long-standing JavaScript quirk.
  • The final return is one typeof check per property, joined with &&, so all of them must pass.
  • The predicate return type value is Product is what lets data.name type-check inside the if. Change it to plain boolean and the body of the if stops compiling, even though the runtime behaviour would be identical.