TypeScript · Lesson 9 of 12

Typing Async Code, fetch and API Responses

Type TypeScript async functions and Promises, wrap fetch in a typed API client, handle errors with Result types and validate JSON at runtime with zod.

  • Intermediate
  • 18 min read
  • 4 objectives

Before this lessonLesson 8: Enums, Modules and Declaration Files

What you will learn

  • Type async functions, Promise.all and Awaited
  • Build a small generic fetch wrapper
  • Validate API responses at runtime with zod
  • Model success and failure with a Result type

Your Progress

0 of 12 lessons 0%

  • Lessons0 / 12
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

Almost every app talks to a server, and that boundary is where TypeScript's guarantees are weakest. Your types describe what you expect the API to send, but the network sends whatever it sends. This lesson shows how to type async code cleanly and how to actually check incoming data so your types stay true.

Async functions return Promises

An async function always returns a Promise. If the body returns a User, the function's type is Promise<User>, and await unwraps it back to User.

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

async function getUser(id: number): Promise<User> {
  return { id, name: "Ada" };
}

async function main() {
  const user = await getUser(1);   // User
  // const bad = getUser(1).name;  // Error: Property 'name' does not exist on type 'Promise<User>'
}

That commented line is a very common bug (forgetting await), and TypeScript catches it. The @typescript-eslint/no-floating-promises lint rule catches the other half: calling an async function and never awaiting or handling it.

Running work in parallel

Promise.all is typed as a tuple: each position keeps its own type. Promise.allSettled never rejects and gives you a discriminated union per result, which you narrow on status.

async function getOrders(userId: number) { return [{ id: "A1", total: 49 }]; }

async function dashboard(id: number) {
  const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
  // user: User, orders: { id: string; total: number }[]

  const results = await Promise.allSettled([getUser(1), getUser(2)]);
  for (const r of results) {
    if (r.status === "fulfilled") console.log(r.value.name);
    else console.log("failed:", r.reason);
  }
}

Here is the runtime behaviour, with one request failing:

const wait = (ms) => new Promise((r) => setTimeout(r, ms));

async function getUser(id) {
  await wait(10);
  if (id === 2) throw new Error("User 2 not found");
  return { id, name: id === 1 ? "Ada" : "Linus" };
}

async function main() {
  const results = await Promise.allSettled([getUser(1), getUser(2), getUser(3)]);
  for (const r of results) {
    if (r.status === "fulfilled") console.log("ok:", r.value.name);
    else console.log("failed:", r.reason.message);
  }
  try {
    await Promise.all([getUser(1), getUser(2)]);
  } catch (err) {
    console.log("Promise.all rejected:", err.message);
  }
}
main();
Output
ok: Ada
failed: User 2 not found
ok: Linus
Promise.all rejected: User 2 not found

A typed fetch wrapper

res.json() returns Promise<any>, which switches off type checking for everything downstream. A small generic wrapper centralises the base URL, headers and error handling and returns a typed value.

class HttpError extends Error {
  constructor(public readonly status: number, message: string) {
    super(message);
    this.name = "HttpError";
  }
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`https://api.stackcone.com${path}`, {
    ...init,
    headers: { "Content-Type": "application/json", ...init?.headers },
  });
  if (!res.ok) throw new HttpError(res.status, `${init?.method ?? "GET"} ${path} failed`);
  return (await res.json()) as T;
}

const user = await request<User>("/users/1");   // User

Runtime validation with zod

A schema library lets you describe the shape once and get two things from it: a runtime validator and a TypeScript type. zod is the most popular choice (valibot and ArkType are lighter alternatives). With zod 4, the API looks like this:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int(),
  name: z.string().min(1),
  email: z.email(),
  role: z.enum(["admin", "member"]).default("member"),
  createdAt: z.coerce.date(),          // turns an ISO string into a Date
});

type User = z.infer<typeof UserSchema>;
// { id: number; name: string; email: string; role: "admin" | "member"; createdAt: Date }

async function getUser(id: number): Promise<User> {
  const json = await request<unknown>(`/users/${id}`);   // unknown, not any
  return UserSchema.parse(json);                         // throws ZodError if invalid
}

The key moves: fetch as unknown (so you cannot use the data before checking it), parse with the schema, and let z.infer produce the type. There is now exactly one source of truth. parse throws on bad data; safeParse returns a result object instead, which is better when you want to show a friendly message.

const result = UserSchema.safeParse({ id: 1, name: "", email: "not-an-email", createdAt: "2026-09-23" });

if (!result.success) {
  console.log(z.flattenError(result.error).fieldErrors);
  // { name: [ 'Too small: expected string to have >=1 characters' ],
  //   email: [ 'Invalid email address' ] }
} else {
  result.data.name;   // fully typed User
}

The same schema can validate form input on the client and request bodies on the server, which is why zod shows up in tRPC, React Hook Form and Next.js server actions.

Errors as values: a Result type

throw is invisible in a function's type: nothing in Promise<User> warns callers that it can fail. For operations where failure is expected (not found, validation errors), returning a discriminated union makes the failure case impossible to ignore.

type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function findUser(id: number): Promise<Result<User>> {
  try {
    return { ok: true, value: await getUser(id) };
  } catch (err) {
    if (err instanceof HttpError && err.status === 404) return { ok: false, error: "not_found" };
    return { ok: false, error: err instanceof Error ? err.message : "unknown" };
  }
}

const r = await findUser(7);
if (r.ok) console.log(r.value.name);   // must check ok before touching value
else console.log("Could not load user:", r.error);

You do not need to use this everywhere. A reasonable split: throw for truly unexpected problems (bugs, server down), return a Result for outcomes the caller should handle as part of normal flow.

Recap

  • An async function returns Promise<T>; forgetting await is a type error you get for free.
  • Promise.all preserves a typed tuple; allSettled gives a union to narrow on status.
  • Treat network data as unknown, never trust as T alone.
  • Validate with a schema (zod) and derive the type with z.infer so runtime and compile time agree.
  • Return a Result union for expected failures so callers cannot forget them.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 10tsconfig, Strict Mode and ToolingConfigure tsconfig.json: what strict mode enables, extra safety flags, module settings, running TypeScript with Node, and type-aware linting.