Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~23 min· topic 4 of 6

Topic 13.4

Fetching Data Safely

In one line

Data from a network is outside TypeScript's reach: response.json() returns any, and declaring a type on it is a promise nobody checks. Treat it as unknown, check it at runtime with a validator, and let a schema give you the type.

Think of it like this

A parcel arrives at your door with a label that says "fragile glass vase". You can read the label, but the label is not the vase. If you plan your day assuming a vase and open a box of bricks, the label didn't protect you. Data from the network is a parcel. A type annotation is the label you stick on it yourself. To know what is inside, you must open the box and look. That is validation.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

fetch
The browser function that makes a network request and returns a promise of a Response.
Response
What fetch gives back: status, headers and body readers like json() and text().
any
A type that switches checking off. Anything can be assigned to it and from it.
unknown
The safe version of any: you can hold anything, but you must check before you use it.
Validation
Checking at runtime that data has the shape you expect.
Type guard
A function returning value is Type. When it returns true, TypeScript narrows the type.
Schema
A description of a data shape, written as a value, used both to check data and to produce the type.
Zod / Valibot
Popular libraries for writing schemas and inferring types from them.
res.ok
true when the HTTP status is 200 to 299.

Step by step

01The silent lie

res.json() is Promise<any>, so the annotation : User is accepted. The server sent the age as text. Nothing complains, and user.age + 1 becomes the text "301".

index.tswhole filets
const res = await fetch(url);
const user: User = await res.json(); // any -> User: no check at all
console.log(user.age + 1);           // "301"

02Say unknown, then prove it

Change the annotation to unknown. Now data.age is an error until you check. A type guard does the check, and TypeScript trusts its answer.

index.tswhole filets
const data: unknown = await res.json();
if (isUser(data)) {
  console.log(data.age + 1); // data is User here
} else {
  console.log("bad data from server");
}
Say unknown, then prove itdiagram
Rendering diagram…

03Describe the shape once

A schema is the single source of truth. The validator and the type come from the same line, so they can't disagree.

schema.tswhole filets
import { z } from "zod";

const User = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof User>; // { name: string; age: number }

const result = User.safeParse(await res.json());
if (result.success) {
  console.log(result.data.age + 1); // result.data is User
} else {
  console.log(result.error.issues[0]?.message);
}

04The same idea in Valibot

Valibot writes the schema as small functions and calls them with the schema first. v.safeParse returns an object with success, and output on success or issues on failure.

schema.tswhole filets
import * as v from "valibot";

const User = v.object({ name: v.string(), age: v.number() });
type User = v.InferOutput<typeof User>;

const result = v.safeParse(User, await res.json());
if (result.success) {
  console.log(result.output.age + 1);
}

05Status first, then body

A 404 does not make fetch throw. Check res.ok before you read the body, and make the failure a value your code handles.

index.tswhole filets
const res = await fetch(url);
if (!res.ok) {
  throw new Error("Request failed with status " + res.status);
}
const data: unknown = await res.json();

Try it yourself

  1. 1

    Fix the lie

    In the first example change const user: User = await res.json(); to const data: unknown = await res.json(); and the last line to console.log(data.age + 1);. Predict the error first. Then you know why unknown is the honest type.

  2. 2

    Weaken the guard

    In the second example delete the two lines that check age ("age" in v && and typeof v.age === "number") and the && at the end of the name line above them. Run it: the second payload is now reported as valid and data.age + 1 prints 301 even though TypeScript says data.age is a number. The compiler trusted the guard.

  3. 3

    Extend the schema

    In the third example add active: boolean support: write a boolean schema like number, add active: boolean to the object({...}), and watch User in the Types panel grow without you touching the type.

Code & diagrams

res.json() is any: the annotation is a lie

No type error, because any is assignable to User. The Console shows the bug the type promised could not happen: text glued to a number.

Console

(nothing was printed)

Expected console output

301
unknown plus a type guard

Both payloads are fetched the same way. The guard decides. Delete the line that checks age and the second payload is wrongly accepted: the compiler trusts the guard, so a guard must check every field.

Console

(nothing was printed)

Expected console output

valid: Asha 31
invalid data: { name: 'Asha', age: '30' }
A tiny schema library, and a type inferred from it

About twenty lines is the core of Zod and Valibot. The type User is computed from the schema, so adding a field to the schema changes the type automatically. The only cast sits where the result object is built after every field has passed.

Console

(nothing was printed)

Expected console output

ok Asha 31
rejected - age: expected number
rejected - name: expected string
rejected - expected object
The same schema in Zod and in Valibot (not live: needs the packages)ts
// npm install zod        (Zod 4 at the time of writing)
import { z } from "zod";

const User = z.object({ name: z.string(), age: z.number() });
type User = z.infer<typeof User>;

const result = User.safeParse({ name: "Asha", age: "30" });
console.log(result.success); // false
console.log(result.error?.issues[0]?.message);
// Invalid input: expected number, received string

// npm install valibot    (Valibot 1 at the time of writing)
import * as v from "valibot";

const UserV = v.object({ name: v.string(), age: v.number() });
type UserV = v.InferOutput<typeof UserV>;

const r = v.safeParse(UserV, { name: "Asha", age: "30" });
console.log(r.success); // false; r.issues lists what went wrong

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use unknown without checking

In the first example change the const user: User = await res.json(); line to const data: unknown = await res.json(); and the last line to console.log(data.age + 1);.

terminal
$ Types panel
── what you'll see ──
index.ts(9,13): error TS18046: 'data' is of type 'unknown'.

Myth vs fact

Myth

const user: User = await res.json() makes the data a User.

Fact

It only tells the compiler to believe it. Nothing is checked, because any is assignable to every type.

Myth

If the server is mine, I don't need to validate.

Fact

Versions drift, deploys are not atomic, caches serve old data, and a proxy can return an HTML error page. Validate at the edge.

Myth

Schema libraries are only for big projects.

Fact

A three-field response is enough to benefit: one definition replaces a hand-written type plus a hand-written guard.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Response.json() returns Promise<any> and JSON.parse returns any because they predate unknown (TypeScript 3.0). Some teams wrap them in a helper fetchJson(url): Promise<unknown>, or enable the lint rule @typescript-eslint/no-unsafe-assignment, which flags any flowing into typed variables.

  • ▸

    A type guard is trusted completely and the compiler doesn't check it against the type. Test your guards, or avoid writing them by hand by using a schema library. The same applies to assertion functions (Topic 4.7).

  • ▸

    Zod and Valibot (and others) implement the shared "Standard Schema" interface, so tools (forms, routers, API clients) can accept any of them. In your own code, accept Schema<T>-like objects where it is cheap, and keep the choice of library in one file.

Remember this

  1. 1

    The problem this solves: data from a server can have any shape, and TypeScript can't see it, so a wrong type written on it protects nothing. This topic shows how to check the data when it arrives so the types you use afterwards are true. fetch(url) returns a Promise<Response> (a promise, see Topic 13.3). The Response has a status (res.ok is true for 200 to 299) and body readers: res.text() gives a string, res.json() parses JSON. TypeScript types res.json() as **Promise<any>**. It has to: the compiler can't see across the network, and the standard library doesn't know your API. The result is any, the type that switches checking off (Topic 1.6).

  2. 2

    Because any is assignable to everything, this compiles with no error: const user: User = await res.json();. It looks safe, but the annotation is a cast in disguise. If the server sends { name: "Asha", age: "30" } (age as a string) or forgets a field, TypeScript still believes user.age is a number. The bug shows up three functions later, with a confusing message. A cast is a lie you tell the compiler; the compiler then repeats it to everyone who uses user.

  3. 3

    The honest type for outside data is **unknown**: "I have a value and I know nothing about it". You can't use an unknown for anything until you check it, and the compiler enforces that. Writing const data: unknown = await res.json() costs one word, and it moves the question "is this really a User?" to the place where the data enters, which is the only place that can answer it.

  4. 4

    A runtime validator is code that opens the box. For a small shape you can write a type guard (Topic 4.7), a function returning v is User, that checks typeof on each field. If it returns true, TypeScript narrows the unknown to User. Be careful: the compiler trusts the guard completely. A guard that forgets a field is a lie too, just a more hidden one. For bigger shapes a guard gets long and drifts out of sync with the type.

  5. 5

    A schema library such as Zod or Valibot fixes both problems by describing the shape once, as a value: z.object({ name: z.string(), age: z.number() }). That one description does two jobs. At runtime, schema.parse(data) checks the data and throws (or safeParse returns a result object) when it doesn't match. At compile time, z.infer<typeof schema> produces the matching TypeScript type, so the type can never drift from the check. They read the current API: Zod is at major version 4 and Valibot at major version 1 (checked on npm, October 2026). Valibot splits into small functions so bundlers can drop what you don't use; Zod has a chainable style.

  6. 6

    You can see how this works by building a tiny one, which the third example below does. Each schema is an object with a parse(value: unknown): T method. Combining schemas into an object schema, and reading T back out with a conditional type, is exactly how the libraries infer types from schemas. The one place inside such a library that still uses a cast is where it builds the result object after every field has passed its check. That cast is small, local and tested, which is the right place for one.

  7. 7

    Check res.ok too. fetch only rejects (throws) when the request itself fails (network down). A 404 or 500 is a successful fetch with res.ok === false, and its body is usually an error message, not your data. Validate the status first and the body second. Never forget that the same rule applies to JSON.parse, localStorage and postMessage: anything that parses text into values gives any or unknown.

Explain it without notes

01

Why does const user: User = await res.json() compile, and why is it dangerous?

02

What is the difference between any and unknown?

03

How does a schema library give you both a check and a type?

04

What must you check besides the body of a fetch response?

Practice

01

Write function parsePoint(v: unknown): { x: number; y: number } | null that returns the point if v is an object with numeric x and y, and null otherwise.

02

Write async function getJson(url: string): Promise<unknown> that throws an error mentioning the status if res.ok is false, otherwise returns the parsed body.

Trade-offs

  • ↔

    Hand-written guards have no dependencies and no bundle cost, but they drift from the types and are tedious beyond a few fields. A schema library adds kilobytes and a learning curve, and removes the drift.

  • ↔

    Validating everything costs time on hot paths. Validate where data crosses a trust boundary (network, storage, user input), and trust types inside your own code.

Done when you can

  • I can explain why res.json() is any and why : User on it is a cast.

  • I can use unknown and narrow it with a type guard.

  • I can write a small runtime validator and say what the compiler trusts.

  • I can explain how a schema gives both a check and an inferred type.

  • I check res.ok before reading the body.