RizTech Academy logo
RizTech Academy
Asynchronous TypeScriptLesson 5 of 525 min

Errors in async code, and unknown in catch

Async code fails — a network drops, a server returns an error, a file is missing. Handling those failures well is where a lot of real-world robustness lives, and TypeScript has one specific rule that surprises everyone and matters enormously: in a catch block, the error is unknown. This final lesson of the async module is error handling done right, and the pattern that ties the whole course together — modelling failure as a value.

The surprising rule: catch gives you unknown

You would expect a caught error to be an Error. It is not:

try {
  await riskyOperation();
} catch (error) {
  error.message;      // error: 'error' is of type 'unknown'
}

Under strict mode (specifically useUnknownInCatchVariables, which strict enables), the error in a catch block has type unknown, not Error. This is correct, even though it is annoying: in JavaScript you can throw anything — an Error, a string, a number, an object, undefined. So TypeScript cannot assume the caught value is an Error; it might be anything. Marking it unknown forces you to check before using it — exactly the safety unknown provides (from the basic-types lesson).

Trying error.message fails because unknown permits nothing until you narrow. This is TypeScript being honest: it does not know the error is an Error, so it makes you find out.

Handling unknown errors correctly

You narrow the error before using it, usually with instanceof Error:

try {
  await riskyOperation();
} catch (error) {
  if (error instanceof Error) {
    console.error(error.message);      // error narrowed to Error — message available
  } else {
    console.error("Unknown error:", error);   // something non-Error was thrown
  }
}

error instanceof Error narrows to Error, unlocking .message, .stack, .name. The else handles the (rare, but real) case of something non-Error being thrown. This is the correct, safe pattern, and it is worth making a habit — a small helper removes the repetition:

function getErrorMessage(error: unknown): string {
  if (error instanceof Error) return error.message;
  return String(error);
}

try {
  await riskyOperation();
} catch (error) {
  console.error(getErrorMessage(error));   // always a string, safely
}

getErrorMessage(error: unknown): string takes the unknown and always returns a usable message — narrowing inside, falling back to String(error) for non-Errors. This helper is common in real codebases, and it captures the whole pattern: treat a caught error as unknown, narrow to Error, and have a fallback.

Always throw Errors, not strings

The reason catch is unknown is that JavaScript allows throwing anything — but you should not. Always throw an Error (or a subclass), never a string or a plain object:

throw new Error("Payment failed");           // correct
throw "Payment failed";                        // wrong — loses the stack trace, and is untyped

class PaymentError extends Error {             // even better — a specific error type
  constructor(public code: string, message: string) {
    super(message);
    this.name = "PaymentError";
  }
}
throw new PaymentError("INSUFFICIENT_FUNDS", "Not enough balance");

Throwing a string loses the stack trace (which tells you where the error came from) and gives handlers nothing to narrow to reliably. Throwing an Error gives a message, a stack, and a type. A custom error subclass (PaymentError extends Error) lets a handler narrow to your specific error and access extra data (error.code) — if (error instanceof PaymentError). If everyone throws Errors, the unknown in catch is easy to handle (instanceof Error always works); the unknown exists precisely because you cannot assume everyone did.

The better pattern: model failure as a value

Here is where the whole course converges. Exceptions (throw/catch) are one way to handle failure, but they have a downside: the type system does not see them. A function's signature says Promise<User>, giving no hint that it might throw — so a caller has no compile-time nudge to handle the failure. For failures that are a normal, expected part of an operation (a validation failing, a resource not found), a discriminated union (from the unions module) makes the failure part of the type:

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

async function fetchUser(id: number): Promise<Result<User>> {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    return { ok: false, error: `HTTP ${response.status}` };
  }
  const user = await response.json();
  return { ok: true, value: user };
}

const result = await fetchUser(1);
if (result.ok) {
  console.log(result.value.name);   // narrowed — value available
} else {
  console.log(result.error);        // narrowed — the caller MUST handle this branch
}

Now the return type — Promise<Result<User>> — tells every caller "this can fail", and the discriminated union forces them to handle both the success and failure branches. The failure is in the type, not hidden in a throw the caller might forget to catch. This is the errors-typed pattern the best-practices module returns to, and it is the async-and-typed version of "make illegal states unrepresentable" — here, "make an unhandled failure impossible", because you cannot get to value without checking ok.

When to use which: throw/catch for exceptional, unexpected failures (a bug, a truly broken precondition) — the failure propagates and you catch it at a boundary. A Result union for expected failures that are part of normal operation (validation, not-found, a declined payment) — the failure is a value the caller handles. Most robust codebases use both, and choosing correctly is a mark of maturity. Libraries like Zod (real-projects module) return a Result-like value from their safeParse for exactly this reason.

Check your work

The type of error in a catch block. unknown (under strict mode) — because JavaScript can throw anything, so TypeScript cannot assume it is an Error.

Why error.message fails in catch. unknown permits nothing until you narrow.

How to handle a caught error. Narrow with instanceof Error to access .message; have a fallback for non-Errors — a getErrorMessage(error: unknown): string helper captures it.

Why always throw Errors. A string loses the stack trace and is not reliably narrowable; an Error gives a message, stack, and type; a custom subclass adds narrowable specifics.

The downside of exceptions for the type system. A function's signature does not reveal it can throw, so callers get no compile-time nudge to handle failure.

What a Result<T> discriminated union does. Puts the failure in the return type, forcing callers to handle both success and failure branches — an unhandled failure becomes impossible.

When to use throw versus Result. throw for exceptional/unexpected failures caught at a boundary; Result for expected failures that are part of normal operation.

Practice

  1. Write a try/catch around an await and try to access error.message. Read the unknown error.
  2. Narrow with if (error instanceof Error) and access .message. Add an else for non-Errors.
  3. Write a getErrorMessage(error: unknown): string helper and use it in a catch.
  4. Throw a string somewhere and observe what a handler can (not) do with it. Then throw an Error and compare.
  5. Write a custom PaymentError extends Error with a code, throw it, and narrow to it in a handler to access code.
  6. Rewrite an async function that throws on failure to return a Promise<Result<T>> instead. Confirm the caller must handle both branches.
  7. For three failures (a bug, a not-found, a validation error), decide throw or Result for each and justify it.

Official documentation

Next module — Data Structures, Typed: the collections you use every day, and immutability.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship