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".
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.
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");
}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.
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.
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.
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
Fix the lie
In the first example change
const user: User = await res.json();toconst data: unknown = await res.json();and the last line toconsole.log(data.age + 1);. Predict the error first. Then you know whyunknownis the honest type. - 2
Weaken the guard
In the second example delete the two lines that check
age("age" in v &&andtypeof v.age === "number") and the&&at the end of thenameline above them. Run it: the second payload is now reported as valid anddata.age + 1prints301even though TypeScript saysdata.ageis a number. The compiler trusted the guard. - 3
Extend the schema
In the third example add
active: booleansupport: write abooleanschema likenumber, addactive: booleanto theobject({...}), and watchUserin the Types panel grow without you touching the type.
Code & diagrams
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
301Both 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' }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// 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 wrongBreak 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);.
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()returnsPromise<any>andJSON.parsereturnsanybecause they predateunknown(TypeScript 3.0). Some teams wrap them in a helperfetchJson(url): Promise<unknown>, or enable the lint rule@typescript-eslint/no-unsafe-assignment, which flagsanyflowing 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
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 aPromise<Response>(a promise, see Topic 13.3). TheResponsehas a status (res.okistruefor 200 to 299) and body readers:res.text()gives a string,res.json()parses JSON. TypeScript typesres.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 isany, the type that switches checking off (Topic 1.6). - 2
Because
anyis 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 believesuser.ageis 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 usesuser. - 3
The honest type for outside data is **
unknown**: "I have a value and I know nothing about it". You can't use anunknownfor anything until you check it, and the compiler enforces that. Writingconst data: unknown = await res.json()costs one word, and it moves the question "is this really aUser?" to the place where the data enters, which is the only place that can answer it. - 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 checkstypeofon each field. If it returnstrue, TypeScript narrows theunknowntoUser. 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
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 (orsafeParsereturns 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
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): Tmethod. Combining schemas into anobjectschema, and readingTback 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
Check
res.oktoo.fetchonly rejects (throws) when the request itself fails (network down). A404or500is a successful fetch withres.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 toJSON.parse,localStorageandpostMessage: anything that parses text into values givesanyorunknown.
Explain it without notes
Why does const user: User = await res.json() compile, and why is it dangerous?
What is the difference between any and unknown?
How does a schema library give you both a check and a type?
What must you check besides the body of a fetch response?
Practice
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.
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()isanyand why: Useron it is a cast.I can use
unknownand 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.okbefore reading the body.