Q23 of 38 · TypeScript

How does TypeScript handle thrown errors in async functions, and why can't you type what a function throws?

TypeScriptMidtypescripterror-handlingasyncresult-typeexceptionsstrict-mode

Short answer

Short answer: TypeScript does not track thrown types — the `throws` clause does not exist in TypeScript. An async function that throws returns a `Promise<never>` (the rejected branch) but the rejection reason is untyped (`unknown` under strict mode). Model expected error paths explicitly with discriminated unions or result types instead.

Detail

TypeScript deliberately has no throws declaration in its type system. This is a design choice: Java-style checked exceptions proved unwieldy in practice, and JavaScript's dynamic nature makes exhaustive tracking impractical.

What this means:

  • A function's signature tells you the resolved type but not the rejected type.
  • async function fetch(): Promise<User> can also reject with any error — the type system won't tell you which errors to handle.
  • Under strict mode, catch (err) receives unknown, requiring explicit narrowing.

Consequence for tests: A test that expects a specific exception type must use runtime checks (instanceof Error, err.message.includes(...)) because TypeScript cannot validate the thrown type.

Better pattern: Return a Result type instead of throwing for expected error paths:

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

This makes error handling explicit and typed at the cost of making happy-path code more verbose.

When throwing is appropriate: Unexpected errors (programming errors, truly exceptional conditions) should throw. Expected business-logic outcomes (validation failure, not-found) benefit from result types.

// EXAMPLE

// TypeScript doesn't track what a function throws
async function fetchUser(id: number): Promise<User> {
  if (id <= 0) throw new RangeError("ID must be positive");
  const res = await fetch(`/api/users/${id}`);
  if (res.status === 404) throw new NotFoundError("User not found");
  return res.json();
}
// Callers have no way to know RangeError or NotFoundError are possible

// Better: Result type for expected errors
type UserResult =
  | { ok: true; user: User }
  | { ok: false; error: "not-found" | "invalid-id" };

async function safeGetUser(id: number): Promise<UserResult> {
  if (id <= 0) return { ok: false, error: "invalid-id" };
  const res = await fetch(`/api/users/${id}`);
  if (res.status === 404) return { ok: false, error: "not-found" };
  return { ok: true, user: await res.json() };
}

// Caller must handle both paths — TypeScript enforces it
const result = await safeGetUser(id);
if (!result.ok) {
  console.error(result.error); // "not-found" | "invalid-id" — typed!
}

// WHAT INTERVIEWERS LOOK FOR

Knowing TypeScript has no checked exceptions by design. The `unknown` catch binding under strict. Result types as the typed alternative. The distinction between exceptional errors (throw) and expected outcomes (result type).

// COMMON PITFALL

Annotating a function as `Promise<User | Error>` to 'type the error path' — this mixes errors with success values in the resolved type, which is confusing. Use a discriminated union result type or throw for genuinely exceptional cases.