TypeScript Fundamentals: Types, Generics and Real-World Patterns · Generics, Utility Types and Real-World TypeScript

Async Code and Typed Error Handling

Type promises and async functions, run tasks in parallel with Promise.all, handle thrown values safely as unknown, and model failures explicitly with a Result type.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

A mock user service: async functions that fetch data, parallel loading with Promise.all, a custom error class, and a Result wrapper so callers cannot forget to handle failure.

The idea

  • Promises are generic. Promise<User> is a promise that resolves to a User. An async function always returns a promise, so async function load(): Promise<User>.
  • await unwraps the promise. Awaited<T> is the type-level equivalent.
  • Promise.all keeps types positional: Promise.all([getUser(), getPosts()]) returns a tuple [User, Post[]].
  • Errors are unknown. In catch (error), TypeScript (with useUnknownInCatchVariables, part of strict) types error as unknown, because anything can be thrown. Narrow with instanceof Error.
  • Custom errors extend Error and carry data (a code, a status). Narrow them with instanceof.
  • A Result type makes failure part of the return type. TypeScript doesn't track which exceptions a function throws, so for expected failures (validation, "not found") returning { ok: false, error } forces the caller to deal with it.

Use exceptions for unexpected problems and Result for expected ones.

The code

Promises, Promise.all, custom errors and a Result typeTry it yourself
interface User {
  id: number;
  name: string;
}

class NotFoundError extends Error {
  constructor(public readonly id: number) {
    super(`user ${id} not found`);
    this.name = "NotFoundError";
  }
}

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

const database: User[] = [
  { id: 1, name: "Ada" },
  { id: 2, name: "Grace" },
];

async function getUser(id: number): Promise<User> {
  await delay(5);
  const user = database.find((u) => u.id === id);
  if (!user) throw new NotFoundError(id);
  return user;
}

async function getPostCount(id: number): Promise<number> {
  await delay(5);
  return id * 3;
}

// Parallel work with a typed tuple result
const [user, posts] = await Promise.all([getUser(1), getPostCount(1)]);
console.log(`${user.name} has ${posts} posts`);

// Errors are unknown: narrow before using them
try {
  await getUser(99);
} catch (error) {
  if (error instanceof NotFoundError) {
    console.log("caught NotFoundError for id", error.id);
  } else if (error instanceof Error) {
    console.log("other error:", error.message);
  }
}

// Turning an exception into a Result so callers must handle both cases
type Result<T> = { ok: true; value: T } | { ok: false; error: string };

async function tryGetUser(id: number): Promise<Result<User>> {
  try {
    return { ok: true, value: await getUser(id) };
  } catch (error) {
    return { ok: false, error: error instanceof Error ? error.message : String(error) };
  }
}

for (const id of [2, 7]) {
  const result = await tryGetUser(id);
  console.log(result.ok ? `found ${result.value.name}` : `failed: ${result.error}`);
}

// Promise.allSettled keeps going when some promises fail
const settled = await Promise.allSettled([getUser(1), getUser(3)]);
console.log(settled.map((s) => s.status));

Output

Ada has 3 posts
caught NotFoundError for id 99
found Grace
failed: user 7 not found
[ 'fulfilled', 'rejected' ]

Common mistakes

  • Forgetting await. The variable becomes a Promise<T>, not a T; the compiler usually warns when you then use it as a T.
  • Awaiting in a loop when the calls are independent. Start them together and await Promise.all(...).
  • Assuming catch (e) gives an Error. Narrow with instanceof Error or check the shape before reading .message.
  • Throwing strings or plain objects. Throw Error subclasses so you get stack traces and instanceof checks.
  • Ignoring rejected promises. Unhandled rejections crash Node.js processes; always await or .catch them.

Key points

  • async functions return Promise<T>; await unwraps it, and Promise.all returns a typed tuple.
  • Thrown values are unknown; narrow with instanceof before using them.
  • Custom Error subclasses carry data and enable precise catch branches.
  • A Result discriminated union makes expected failures explicit in the type.

Further reading: The Handbook: Narrowing and MDN: async function.

Practice questions

Why is the variable in `catch (error)` typed `unknown` and how should you use it?

Answer

JavaScript allows throwing any value, so TypeScript cannot know its type. Narrow it first, for example with error instanceof Error before reading error.message, or with a custom error class check.

When would you return a Result type instead of throwing?

Answer

For expected, recoverable failures such as validation or a missing record. A Result forces the caller to handle both cases at compile time, while exceptions are better for unexpected faults.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?