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.
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:
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());HELLO, ASHAForgetting await is caught, because a Promise<string> is not a string:
async function loadGreeting(name: string): Promise<string> {
return `Hello, ${name}`;
}
const greeting = loadGreeting("Asha");
console.log(greeting.toUpperCase());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:
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);Leanne Graham Sincere@april.bizThis 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:
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);1 false delectus aut autemFrom 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:
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);10 1Typed error handling#
In strict mode, catch variables are unknown (any, unknown and never):
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"));ok
failed: HTTP 404A 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:
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);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
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));Ervin Howell
nullKey takeaway#
Finished this lesson?