Mert Özen Further With Every Line
Frontend TypeScript Intermediate — #10

Asynchronous Code in TypeScript: Typing Promise, async and fetch Responses

Mert Özen Nov 3, 2026 10 min 2 views
Asynchronous Code in TypeScript: Typing Promise, async and fetch Responses

We look at Promise<T> and async return types, why a fetch response is any, runtime validation with unknown and hand-written type guards, and the types of Promise.all and allSettled.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

We covered async functions and promises in detail in the JavaScript series. Now let's bring that knowledge together with the type system:

async function getCount() {
  return 42;
}

const count = getCount();
console.log(count + 1);

Does this code compile? If it does, what does it print; if it doesn't, what does TypeScript say? Hint: think about the type of count.

Promise<T>: The Type of a Value That Hasn't Arrived Yet

Promise is one of the most familiar examples of the generics we've been discussing since the start of the Intermediate series. Promise<T> means "an object that will give you a value of type T later." Think of it as a parcel tracking number: you know what's in the parcel, but the parcel isn't in your hands yet. You can't open the parcel with a tracking number; you have to wait for it to arrive first.

function wait(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function rollDice(): Promise<number> {
  return new Promise((resolve) => {
    setTimeout(() => resolve(Math.ceil(Math.random() * 6)), 100);
  });
}

const untyped = new Promise((resolve) => resolve(42)); // Promise<unknown>
const typed = new Promise<number>((resolve) => resolve(42)); // Promise<number>

// Error: Argument of type 'string' is not assignable to parameter of type 'number | PromiseLike<number>'.
const wrong = new Promise<number>((resolve) => resolve("42"));

wait gives no value; it just says "done", which is why it's Promise<void>. rollDice will give a number. In these two functions we didn't pass a type parameter to new Promise, because the return type annotation already tells TypeScript what to expect.

The untyped line is a small trap. If you write new Promise on its own, with no context, TypeScript doesn't infer a type from the value you pass to resolve, and the result is Promise<unknown>. Either pass the type parameter explicitly or write the function's return type. Once you pass the type parameter, resolve is checked too; you can't call it with a value of the wrong type, as on the wrong line.

The Return Type of async Functions

Back to the opening question. An async function always returns a Promise, no matter what you return inside it. getCount says 42 inside, but the function's return type is Promise<number>. count isn't a number; it's a number's tracking number. That's why the code doesn't compile:

error TS2365: Operator '+' cannot be applied to types 'Promise<number>' and '1'.

In the editor you also see a hint under the same error: "Did you forget to use 'await'?". In plain JavaScript this code would run and print [object Promise]1; the coercion you know from the JavaScript series would quietly kick in again. Here's the correct version:

async function getCount() {
  return 42;
}

async function main() {
  const count = await getCount(); // number
  console.log(count + 1); // 43
}

main();

// Error: The return type of an async function or method must be the global Promise<T> type.
async function getTotal(): number {
  return 100;
}

await unwraps Promise<number> and gives you number. This is the value-level counterpart of the Awaited we saw earlier. The last function is a common mistake: if you annotate the return type of an async function, you have to write Promise<number>, not the type of the inner value. The compiler suggests this at the end of the message.

When should you write the return type? The rule from the Basics series applies here too: for small helper functions inference is enough, but for functions that other files use and that talk to the outside world, an explicit return type like Promise<Todo> both documents the function and stops you from accidentally returning something else inside it.

Why Is a fetch Response any?

Let's work with a real API. In the examples we'll use jsonplaceholder.typicode.com, which is publicly available for testing. The /todos/1 endpoint returns this JSON: { "userId": 1, "id": 1, "title": "delectus aut autem", "completed": false }.

type Todo = { userId: number; id: number; title: string; completed: boolean };

async function getTodoUnsafe(id: number) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  const data = await response.json(); // any
  console.log(data.titel); // undefined
  return data;
}

async function getTodoTrusting(id: number): Promise<Todo> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  return response.json();
}

getTodoUnsafe(1);

fetch itself is well typed: it returns Promise<Response>, response.ok is a boolean, and response.status is a number. But the return type of response.json() is Promise<any>. That makes sense: TypeScript can't know what the server will send. So data ends up as any, the type we said to avoid in the Basics series. A typo like data.titel compiles and quietly gives undefined at runtime.

getTodoTrusting looks better, but it isn't. We wrote Promise<Todo> as the return type, and since any is assignable to everything, TypeScript didn't object at all. This is a type assertion in disguise: we said "the server will definitely send a Todo" without checking anything. If the API one day renames title to name, you'll be working with the Todo type but wrong data all over your code. The same goes for response.json() as Todo or a generic helper like getJson<T>(); the generic just moves the assertion somewhere else.

Start with unknown, Validate by Hand

The safe way is to treat data coming from outside as unknown and validate it with the type guards we saw in the previous post:

type Todo = { userId: number; id: number; title: string; completed: boolean };

function isTodo(value: unknown): value is Todo {
  return (
    typeof value === "object" &&
    value !== null &&
    "userId" in value && typeof value.userId === "number" &&
    "id" in value && typeof value.id === "number" &&
    "title" in value && typeof value.title === "string" &&
    "completed" in value && typeof value.completed === "boolean"
  );
}

async function fetchTodo(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("Unexpected response shape");
  }
  return data;
}

async function main() {
  const todo = await fetchTodo(1);
  console.log(todo.title); // delectus aut autem

  try {
    await fetchTodo(999);
  } catch (error) {
    console.log(error instanceof Error ? error.message : error); // Request failed with status 404
  }
}

main();

The key line is const data: unknown = await response.json();. We deliberately turn any into unknown; now we can't do anything with data until we check it. isTodo does that check. The "id" in value && typeof value.id === "number" pattern inside it has worked since TypeScript 4.9: after the in check, TypeScript knows value has an id property and lets you check its type. Once the check passes, data is a Todo and matches the return type.

Two more details. The first is the response.ok check: as you'll remember from the JavaScript series, fetch only rejects on network errors; an HTTP error like 404 still arrives as a successful Response. On jsonplaceholder, the nonexistent /todos/999 returns 404 and we turn it into our own error. The second is error inside catch: in strict mode its type is unknown, so we narrow it with instanceof Error before reading message. We'll talk about error handling in a separate post.

In real projects, schema validation libraries are used instead of writing these checks by hand; you define the schema once, and both the type and the validation function come from it. But the underlying idea is the same, and it's worth writing it by hand first to see what they solve.

Multiple Requests: Promise.all and Promise.allSettled

Using the same pattern, let's write two more functions that fetch a user and that user's todo list. We're using the Todo and isTodo from above:

type User = { id: number; name: string; email: string };

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value && typeof value.id === "number" &&
    "name" in value && typeof value.name === "string" &&
    "email" in value && typeof value.email === "string"
  );
}

async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  const data: unknown = await response.json();
  if (!isUser(data)) throw new Error("Unexpected user shape");
  return data;
}

async function fetchTodos(userId: number): Promise<Todo[]> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos?userId=${userId}`);
  const data: unknown = await response.json();
  if (!Array.isArray(data) || !data.every(isTodo)) throw new Error("Unexpected todos shape");
  return data;
}

/users/1 actually returns many more fields: address, phone, company details. isUser only looks at the three fields we need; thanks to structural typing, the extra fields are no problem. In fetchTodos we took advantage of a feature of every: when the callback is a type predicate, every narrows the array too. Once Array.isArray and data.every(isTodo) pass, data is Todo[].

Now let's run the two in parallel:

async function loadDashboard(userId: number) {
  const [user, todos] = await Promise.all([fetchUser(userId), fetchTodos(userId)]);
  // user: User, todos: Todo[]
  const done = todos.filter((todo) => todo.completed).length;
  console.log(`${user.name}: ${done}/${todos.length} done`); // Leanne Graham: 11/20 done
}

async function loadMany(ids: number[]) {
  const results = await Promise.allSettled(ids.map((id) => fetchTodo(id)));
  for (const result of results) {
    if (result.status === "fulfilled") {
      console.log("ok", result.value.id);
    } else {
      console.log("failed", result.reason instanceof Error ? result.reason.message : result.reason);
    }
  }
}

loadDashboard(1).then(() => loadMany([1, 999]));
// ok 1
// failed Request failed with status 404

When you pass an array literal to Promise.all, TypeScript types the result as a tuple: [User, Todo[]]. Each position keeps its own type, so after destructuring user is a User and todos is a Todo[]; you don't get a useless mix like (User | Todo[])[]. When you pass an array whose length isn't known, like ids.map(...), the result is a plain array.

Promise.allSettled doesn't stop on any request's failure and returns a PromiseSettledResult<Todo> for each result. That's a discriminated union: if the status field is "fulfilled" there's a value, and if it's "rejected" there's a reason. The narrowing we saw in the Basics series works here just the same. The only catch is that reason is typed any; since promises can be rejected with anything, TypeScript can't know better. It's best to check it before use, just like error.

Common Pitfalls

async function isLoggedIn() {
  return false;
}

async function greet() {
  // Error: This condition will always return true since this 'Promise<boolean>' is always defined.
  if (isLoggedIn()) {
    console.log("Welcome back");
  }
}

async function saveAll(ids: number[]) {
  ids.forEach(async (id) => {
    await new Promise((resolve) => setTimeout(resolve, 10));
    console.log("saved", id);
  });
  console.log("all saved?");
}

saveAll([1, 2]);
// all saved?
// saved 1
// saved 2

The first is forgetting await in a condition. A Promise object is always truthy, so if (isLoggedIn()) always runs. TypeScript catches this and, along with the TS2801 error, gives you the "Did you forget to use 'await'?" hint again. That's the good news; the bad news is that not every forgotten await is this visible. tsc doesn't catch a promise whose result is never used (a floating promise); there are linter rules for that.

The second is an async callback inside forEach. This code compiles cleanly, because the return type of the forEach callback is void, and as we saw in the Basics series you can pass a function that returns a value where a void-returning callback is expected. But forEach doesn't wait for the promises it gets back; "all saved?" appears in the output before the saves. If you want to wait one by one, use await inside for...of; if you want them in parallel, use await Promise.all(ids.map(...)).

The third is the Promise<Todo> lie we saw above. Assigning response.json() to a type or casting it with as doesn't validate the data. Everything that comes over the network is unknown until you check it.

A Small Experiment

★☆☆ Predict, then confirm by hovering over the function in your editor: what is the return type of async function maybe(flag: boolean) { if (flag) return "yes"; }?

★★☆ The /posts/1 endpoint on jsonplaceholder returns the fields { userId, id, title, body }. Write a Post type, an isPost type guard and a fetchPost function that returns Promise<Post>. Then deliberately check content instead of body in isPost and see where the error shows up.

★★★ All the functions in this post repeat the same pattern. Write a generic fetchValidated function that takes a URL and a type guard: the return type of fetchValidated("https://jsonplaceholder.typicode.com/todos/1", isTodo) should be Promise<Todo>. Hint: the type of the guard parameter is (value: unknown) => value is T.

In this post, Todo, isTodo, fetchTodo and main all lived in the same file. In a real project the types sit in one file, the guards in another and the API calls in a third. In the next post we move on to modules: import and export in TypeScript, import type for carrying only types, and why that matters when you run code with node file.ts.