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 withinstanceofor 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
| Aspect | throw / try-catch | Result<T, E> |
|---|---|---|
| Visible in signature | no | yes |
| Caller forced to handle | no | yes (must narrow) |
| Boilerplate | low | moderate |
| Best for | unexpected/exceptional failures | expected, recoverable outcomes |
Best Practices
- Never assume the catch variable is an
Error; it isunknown— narrow before use. - Narrow with
instanceof Erroror a normalizing helper liketoError(value). - Avoid
catch (err: any); it silently discards type safety. - Subclass
Errorfor domain failures and branch on them withinstanceof. - Use
Result<T, E>to make expected, recoverable failures explicit in the signature. - Reserve thrown exceptions for truly exceptional, unrecoverable conditions.
- Always
awaitpromises insidetryblocks — an un-awaited rejected promise escapes thecatch.