Skip to Content
TypeScript Intermediate: Types That Survive Real Projects
Intermediate
0 likes
Courses
TypeScript Intermediate: Types That Survive Real Projects

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.

Course Home

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 Todo straight 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
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.