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
- Write a
try/catcharound anawaitand try to accesserror.message. Read theunknownerror. - Narrow with
if (error instanceof Error)and access.message. Add anelsefor non-Errors. - Write a
getErrorMessage(error: unknown): stringhelper and use it in a catch. - Throw a string somewhere and observe what a handler can (not) do with it. Then throw an
Errorand compare. - Write a custom
PaymentError extends Errorwith acode, throw it, and narrow to it in a handler to accesscode. - Rewrite an async function that throws on failure to return a
Promise<Result<T>>instead. Confirm the caller must handle both branches. - For three failures (a bug, a not-found, a validation error), decide
throworResultfor each and justify it.
Official documentation
- TypeScript — useUnknownInCatchVariables — Why
catchisunknown. - MDN — Error — The Error object and custom subclasses.
- TypeScript — Handling errors — Narrowing caught errors.
- Zod — safeParse — The Result pattern for validation, from a real library.
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