Typing fetch JSON Without Lying to Yourself
See why response.json() gives you any, why a cast checks nothing and how a guard function and one generic helper fix it. After this, data from a server only gets a type it has earned.
Course content
Sign in to save your progress.
-
Generics Without the Fog
-
Generic Functions That Keep the Input Type
-
Generic Types for Lists, Results and Caches
-
-
-
Types That Transform Other Types
-
Utility Types: Partial, Pick, Omit and Record
-
Mapped Types You Can Actually Read
-
-
-
Working With Code You Didn't Write
-
@types Packages and When DefinitelyTyped Is Wrong
-
Typing fetch JSON Without Lying to Yourself
-
-
-
Classes and Modules in TypeScript
-
Classes With public, private and protected
-
Modules, Exports and Avoiding Circular Types
-
-
-
Strictness That Pays Off
-
unknown vs any and Safe Parsing
-
Discriminated Unions for API Responses That Vary
-
-
- Final Check: TypeScript Intermediate
Skip ahead: If you already know that response.json() gives you any and that as Todo is not a check, skip to "Check the data, then say the type", then continue.
Every fetch call is a place where data from outside your program walks into it. The compiler cannot see across that line. It does not know what the server sends. What it does know is the type you tell it, and that is where most TypeScript code starts lying. This lesson uses a public test API called JSONPlaceholder, so the output you see is from a real response.
What response.json() gives you
async function main() {
const response = await fetch("https://jsonplaceholder.typicode.com/todos/1");
const data = await response.json();
console.log(data.title);
console.log(data.titel);
}
main();
delectus aut autem
undefined
json() returns Promise<any>. So data is any, and data.titel compiles without a word. It prints undefined because there is no such field. Nothing was checked, and you got no warning.
The first fix, and why it is not enough
The usual move is to describe the shape and cast.
type Todo = { id: number; title: string; completed: boolean };
async function getTodo(id: number): Promise<Todo> {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
return (await response.json()) as Todo;
}
async function main() {
const good = await getTodo(1);
console.log(good.title.toUpperCase());
try {
const missing = await getTodo(99999);
console.log(missing.title.toUpperCase());
} catch (error) {
console.log((error as Error).message);
}
}
main();
DELECTUS AUT AUTEM
Cannot read properties of undefined (reading 'toUpperCase')
Todo 1 works. Todo 99999 does not exist, so the server answers 404 with an empty object. The cast did not check that. It told the compiler to believe you, so missing.title has the type string and is undefined at run time. The compiler is fine and the program is not. That is the lie: the type says one thing and the data says another.
⚠ Warning: as Todo is a claim, not a check. It also does not look at the HTTP status. A 404 response with an error body walks through the cast the same way.
Check the data, then say the type
The honest version has two steps. Treat what comes in as unknown, which means "I know nothing about this yet". Then write a function that really looks at it, and let that function tell the compiler what it proved.
type Todo = { id: number; title: string; completed: boolean };
function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) {
return false;
}
const item = value as Record<string, unknown>;
return (
typeof item.id === "number" &&
typeof item.title === "string" &&
typeof item.completed === "boolean"
);
}
async function getTodo(id: number): Promise<Todo> {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data: unknown = await response.json();
if (!isTodo(data)) {
throw new Error("The server sent something that is not a todo");
}
return data;
}
async function main() {
console.log((await getTodo(1)).title.toUpperCase());
try {
await getTodo(99999);
} catch (error) {
console.log((error as Error).message);
}
}
main();
DELECTUS AUT AUTEM
Request failed with status 404
Three things changed. The status is checked before anything is parsed. The parsed value is stored as unknown, so the compiler stops you from using it before you have checked. And value is Todo in the return type of isTodo is a type predicate: when the function returns true, the compiler treats the argument as a Todo from then on. The as Record<string, unknown> inside the function is the one cast you allow yourself, and it is safe because you check each field right after.
Make it reusable with a generic
Writing getTodo, getUser and getOrder by hand repeats the same fetch and status code. A generic function takes the guard as an argument and returns whatever the guard proves.
type Todo = { id: number; title: string; completed: boolean };
function isTodo(value: unknown): value is Todo {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Todo).id === "number" &&
typeof (value as Todo).title === "string"
);
}
async function fetchJson<T>(url: string, guard: (data: unknown) => data is T): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data: unknown = await response.json();
if (!guard(data)) {
throw new Error("Unexpected response shape");
}
return data;
}
async function main() {
const todo = await fetchJson("https://jsonplaceholder.typicode.com/todos/2", isTodo);
console.log(todo.id, todo.title);
}
main();
2 quis ut nam facilis et officia qui
todo is a Todo, and you did not write as Todo anywhere at the call site. If a caller passes a guard for the wrong type, the compiler cannot help, but a wrong guard is one place to fix, not fifty casts.
Do you have to write guards by hand?
No. Schema libraries such as zod describe the shape once and give you both the check and the type. I am not going to teach one here, because the idea is the same: check at the border, then trust the type inside. Writing one guard yourself first is worth the ten minutes, because you will understand what the library is doing for you.
Common mistakes
- Writing
as Todostraight after.json()and calling the function type-safe. - Skipping
response.ok. A failed request often returns a perfectly valid JSON error body. - Checking only that the value is an object. A
{}passes that check. - Checking the fields you use today and forgetting that the same type is used elsewhere.
💡 Tip: Keep every fetch behind one function with an honest return type. The rest of the code then never sees any.
If you skip this, the types in your project describe what you hoped the server sends, and the bug report arrives on a day the server sends something else. Where in your code does data from outside get a type it has not earned?
About
See why response.json() gives you any, why a cast checks nothing and how a guard function and one generic helper fix it. After this, data from a server only gets a type it has earned.
- Duration
- 5 min
- Last update
- 10/21/2026
Comments (0)
Sign in to commentNo comments yet.