Error Handling Patterns

Intermediate
12 min

Error Handling Patterns

JavaScript lets you throw anything, and a catch block receives whatever was thrown. TypeScript cannot know what that is, so under strict the caught value is unknown, and your code must narrow it before use. This lesson shows how to do that cleanly, how to design custom error classes that carry structured data, when to return a Result instead of throwing, and how assertion helpers keep the happy path readable.

catch Receives unknown

With useUnknownInCatchVariables (part of strict), the variable in a catch clause is unknown. Narrow it with instanceof or a small helper before touching properties:

typescript
try { await saveOrder(order); } catch (err) { if (err instanceof Error) { console.error(err.message); } else { console.error("Non-error thrown:", err); } }

Because libraries and older code sometimes throw strings or plain objects, many teams normalise once:

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

Custom Error Classes

Extending Error gives you instanceof checks and a place for structured fields such as a machine-readable code or an HTTP status. Set name so stack traces and logs show the subclass:

typescript
class HttpError extends Error { constructor(readonly status: number, message: string, options?: { cause?: unknown }) { super(message, options); this.name = "HttpError"; } } class ValidationError extends Error { constructor(readonly issues: { field: string; message: string }[]) { super("Validation failed"); this.name = "ValidationError"; } } try { throw new HttpError(404, "Not found", { cause: new Error("row missing") }); } catch (err) { if (err instanceof HttpError && err.status === 404) { /* handle */ } }

The cause option (ES2022) chains the original error to the one you throw, preserving the root cause for logging. It requires lib or target of es2022 or later. If you compile to es5, add Object.setPrototypeOf(this, new.target.prototype) in the constructor, or instanceof will not recognise subclasses.

Result Types: Errors as Values

Throwing is invisible in a function signature: getUser(id): User says nothing about failure. For expected failures such as "not found" or "invalid input", returning a discriminated union makes the failure part of the type and forces callers to handle it:

typescript
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }; function parseJson<T>(text: string, guard: (v: unknown) => v is T): Result<T, SyntaxError | TypeError> { try { const data: unknown = JSON.parse(text); return guard(data) ? { ok: true, value: data } : { ok: false, error: new TypeError("Wrong shape") }; } catch (err) { return { ok: false, error: err as SyntaxError }; } } const parsed = parseJson('{"id":1}', isUser); if (!parsed.ok) { console.error(parsed.error.message); // parsed.error: SyntaxError | TypeError } else { parsed.value.name; // parsed.value: User }

| Approach | Use for | Trade-off | |---|---|---| | throw | Bugs and unrecoverable failures | Invisible in types; unwinds the stack for free | | Result union | Expected, recoverable outcomes | Explicit in types; callers must branch | | null / undefined return | Simple "not found" | Loses the reason for failure |

Functions That Never Return

A function that always throws has return type never. Declaring it lets control-flow analysis treat the call as terminal, so code after it needs no extra checks:

typescript
function fail(message: string): never { throw new Error(message); } function loadPort(env: NodeJS.ProcessEnv): number { const raw = env.PORT ?? fail("PORT is required"); // raw: string return Number(raw); }

Assertion functions (asserts condition, covered in the type guards lesson) combine this with narrowing: assert(user, "missing user") both throws and removes undefined from user.

Cleanup With finally and using

finally runs whether or not an error occurred and is the place to release locks, timers and connections. TypeScript 5.2 also supports explicit resource management: a using declaration calls the object's [Symbol.dispose]() at the end of the block (await using calls [Symbol.asyncDispose]()), even when an exception is thrown. Add esnext.disposable to lib and a polyfill if the runtime lacks it.

Tips

  • Throw Error subclasses, never strings; strings have no stack trace.
  • Keep error classes few; a code field scales better than dozens of subclasses.
  • Log the cause chain once at the boundary (request handler, CLI entry), not at every layer.
Quick Quiz
Question 1 of 3

What is the type of `err` in `catch (err) { ... }` under `strict`?

Key Takeaways

  • Under strict, catch variables are unknown; narrow with instanceof Error or a toError helper.
  • Custom error classes should extend Error, set name, and carry structured fields such as code or status.
  • Use cause to chain errors and keep the root cause available at the logging boundary.
  • Return a Result discriminated union for expected failures; reserve throw for bugs and unrecoverable states.
  • Functions that always throw return never, which lets control-flow analysis skip unreachable checks.

Next lesson: Decorators — annotate classes and members with reusable behaviour using the standard decorator syntax.

Error Handling Patterns - TypeScript | CodeYourCraft | CodeYourCraft