Skip to content
TypeScript ts async 4 min read

Async Error Handling

Error handling is where async code most often goes wrong, and TypeScript enforces a discipline that surprises newcomers: the value caught in a catch block is typed unknown, not Error. That is correct, because JavaScript lets you throw any value at all — a string, a number, null, or an Error. This page shows how to narrow caught values safely, how try/catch interacts with await, how to build a reusable error-handling utility, and how Result-style return types make failure an explicit, type-checked part of a function’s signature instead of a hidden side effect.

Errors are unknown in catch

Under modern TypeScript (with useUnknownInCatchVariables, on by default in strict mode since 4.4), the catch binding is unknown. You must narrow it before accessing .message or any error-specific property.

try {
  await riskyOperation();
} catch (err) {
  // err: unknown
  // ❌ Error: 'err' is of type 'unknown'
  console.log(err.message);
}

Accessing err.message directly is rejected because err could be anything. This forces you to confirm the runtime shape first, preventing the classic crash where code assumes an Error but receives a string.

Narrowing caught values

Use instanceof Error to narrow to the Error type, or build a reusable helper that always yields an Error. After the guard, the error’s properties become accessible and fully typed.

function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  return new Error(typeof value === "string" ? value : JSON.stringify(value));
}

try {
  await riskyOperation();
} catch (err) {
  if (err instanceof Error) {
    console.error(err.message, err.stack); // err: Error
  } else {
    console.error("Unknown failure", err);
  }

  // Or normalize unconditionally:
  throw toError(err);
}

The instanceof Error check narrows err to Error inside the if, exposing .message and .stack. The toError helper guarantees an Error regardless of what was thrown, which is handy at the top of a handler that re-throws or logs.

Avoid annotating the catch variable as catch (err: any) to silence the error. That throws away the safety; narrow with instanceof or a type guard instead.

Custom error classes

Subclassing Error lets you carry structured data and distinguish failure kinds with instanceof. Set the name and remember TS targets below ES2015 need a Object.setPrototypeOf fix.

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

async function load(url: string) {
  const res = await fetch(url);
  if (!res.ok) throw new HttpError(res.status, `Request failed: ${url}`);
  return res.json();
}

try {
  await load("/api/data");
} catch (err) {
  if (err instanceof HttpError && err.status === 404) {
    console.warn("Not found");
  }
}

Because HttpError is a class, err instanceof HttpError narrows to it and exposes the typed status field, letting you branch on specific failure conditions cleanly.

Result-style return types

Throwing is invisible in a function’s type signature — callers cannot tell from the types that a function may fail. A Result<T, E> union makes success and failure explicit, forcing the caller to handle both via a discriminated union.

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

async function tryLoad(url: string): Promise<Result<User>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
    return { ok: true, value: (await res.json()) as User };
  } catch (err) {
    return { ok: false, error: toError(err) };
  }
}

const result = await tryLoad("/api/user/1");
if (result.ok) {
  console.log(result.value.name); // value: User
} else {
  console.error(result.error.message); // error: Error
}

The ok field is the discriminant: checking result.ok narrows the union so result.value is available only on success and result.error only on failure. This style is common in functional codebases and pairs well with libraries like neverthrow, trading exceptions for explicit, type-checked control flow.

Throwing vs Result types

Aspectthrow / try-catchResult<T, E>
Visible in signaturenoyes
Caller forced to handlenoyes (must narrow)
Boilerplatelowmoderate
Best forunexpected/exceptional failuresexpected, recoverable outcomes

Best Practices

  • Never assume the catch variable is an Error; it is unknown — narrow before use.
  • Narrow with instanceof Error or a normalizing helper like toError(value).
  • Avoid catch (err: any); it silently discards type safety.
  • Subclass Error for domain failures and branch on them with instanceof.
  • Use Result<T, E> to make expected, recoverable failures explicit in the signature.
  • Reserve thrown exceptions for truly exceptional, unrecoverable conditions.
  • Always await promises inside try blocks — an un-awaited rejected promise escapes the catch.
Last updated June 29, 2026
Was this helpful?