Skip to content
Laviyx Learn

Try

TypeScript course menu

Async TypeScript: Typing Promises, async Functions, fetch and API Data

Type asynchronous code in TypeScript: Promise<T> and async function return types, typing fetch responses safely, why response.json() returns any, validating API data with type guards, typed error handling, Promise.all with tuples, and a reusable typed API client.

Advanced8 min readUpdated
On this page

Asynchronous code works the same in TypeScript as in JavaScript. TypeScript adds types for what promises resolve to, and helps you handle the biggest risk in async code: data from the network, which TypeScript cannot see.

Promise<T> and async functions#

A promise is typed by the value it resolves to: Promise<string>, Promise<User[]>. An async function always returns a promise:

TypeScript
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

async function loadGreeting(name: string): Promise<string> {
  await wait(10);
  return `Hello, ${name}`;
}

const greeting = await loadGreeting("Asha");   // string
console.log(greeting.toUpperCase());
Output
HELLO, ASHA

Forgetting await is caught, because a Promise<string> is not a string:

TypeScript
async function loadGreeting(name: string): Promise<string> {
  return `Hello, ${name}`;
}
const greeting = loadGreeting("Asha");
console.log(greeting.toUpperCase());
Compiler error
error TS2339: Property 'toUpperCase' does not exist on type 'Promise<string>'.

fetch and the any trap#

response.json() returns Promise<any>, because TypeScript cannot know what a server will send. Any type you write for it is a promise you are making, not something TypeScript checks:

TypeScript
interface User {
  id: number;
  name: string;
  email: string;
}

const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user: User = await response.json();      // trusted, not checked
console.log(user.name, user.email);
Output
Leanne Graham Sincere@april.biz

This works when the API behaves. If it changes or returns an error object, the code fails later in confusing ways. Safer patterns follow.

Validating responses#

Treat the JSON as unknown, and check it with a type guard before use:

TypeScript
interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

function isTodo(value: unknown): value is Todo {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return typeof v.id === "number" && typeof v.title === "string" && typeof v.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(`HTTP ${response.status}`);
  const data: unknown = await response.json();
  if (!isTodo(data)) throw new Error("Unexpected response shape");
  return data;
}

const todo = await getTodo(1);
console.log(todo.id, todo.completed, todo.title);
Output
1 false delectus aut autem

From here on, todo really is a Todo. Validation libraries such as Zod do the same with less code: you define a schema once, it validates data at runtime, and the TypeScript type is derived from it.

A reusable typed client#

Generics let one function fetch any endpoint while keeping each call's types, using a validator for the expected shape:

TypeScript
type Validator<T> = (value: unknown) => value is T;

async function getJson<T>(url: string, isValid: Validator<T>): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);
  const data: unknown = await response.json();
  if (!isValid(data)) throw new Error(`Invalid data from ${url}`);
  return data;
}

interface Post {
  id: number;
  title: string;
}

const isPost: Validator<Post> = (v): v is Post =>
  typeof v === "object" && v !== null && typeof (v as Post).id === "number" && typeof (v as Post).title === "string";

const isPostList: Validator<Post[]> = (v): v is Post[] => Array.isArray(v) && v.every(isPost);

const posts = await getJson("https://jsonplaceholder.typicode.com/posts?userId=1", isPostList);
console.log(posts.length, posts[0]?.id);
Output
10 1

Typed error handling#

In strict mode, catch variables are unknown (any, unknown and never):

TypeScript
async function safeGet(url: string): Promise<string> {
  try {
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return "ok";
  } catch (error) {
    return error instanceof Error ? `failed: ${error.message}` : "failed: unknown error";
  }
}

console.log(await safeGet("https://jsonplaceholder.typicode.com/posts/1"));
console.log(await safeGet("https://jsonplaceholder.typicode.com/nope"));
Output
ok
failed: HTTP 404

A discriminated union result type, { ok: true; data: T } | { ok: false; error: string }, is another clean way to return errors from async functions (Discriminated Unions).

Promise.all keeps each type#

Promise.all with an array of different promises returns a tuple with each result's type:

TypeScript
const wait = <T,>(ms: number, value: T) => new Promise<T>((r) => setTimeout(() => r(value), ms));

const [count, names, active] = await Promise.all([
  wait(10, 42),
  wait(5, ["Asha", "Ravi"]),
  wait(1, true),
]);
// count: number, names: string[], active: boolean

console.log(count + 1, names.join(" & "), !active);
Output
43 Asha & Ravi false

(The comma in <T,> is needed only in .tsx files, where <T> alone would look like JSX; it is harmless elsewhere.)

Practice#

Practice exercise

Write async function getUserName(id: number): Promise<string | null> that fetches https://jsonplaceholder.typicode.com/users/{id}, returns null for a 404, validates that the response has a string name, and returns it. Test it with ids 2 and 999.

Show solution
TypeScript
async function getUserName(id: number): Promise<string | null> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`HTTP ${response.status}`);

  const data: unknown = await response.json();
  if (typeof data === "object" && data !== null && "name" in data && typeof data.name === "string") {
    return data.name;
  }
  throw new Error("Unexpected response");
}

console.log(await getUserName(2));
console.log(await getUserName(999));
Output
Ervin Howell
null

Key takeaway#

Finished this lesson?