Skip to content

20 β€” Error Handling Done Properly

Level: Intermediate Β· Time: ~35 min Β· Prerequisites: Lesson 19 β€” Promises and async–await


Why this matters

Lesson 19 showed how await turns a rejected promise back into a thrown exception and left the real question open: what do you do with it? In a program that reads files, calls a public API and parses data somebody else produced, failure is the normal case β€” PVGIS is down, the CSV has a missing column, the endpoint answers with an HTML page instead of JSON. Doing this properly means the failure says what actually broke, the program stays in a state you can reason about, and it stops when the problem appears rather than an hour later with half-valid rows already stored. TypeScript is unusually helpful here, because catch hands you a value typed unknown and will not let you pretend you know what it is.

catch hands you unknown, and that is the point

throw new Error("boom") is only a convention: throw "boom", throw 42 and Promise.reject(undefined) are equally legal, and nothing stops a library doing it. The checker therefore cannot know what was thrown, so under strict the variable in catch is unknown, and reading a property from it is a compile error rather than a silent undefined.

import { readFile } from "node:fs/promises";

try {
  const raw = await readFile("./data/plants.json", "utf8");
  console.log(`${raw.length} characters`);
} catch (error) {
  console.log(error.message); // ❌ TS18046 β€” 'error' is of type 'unknown'
}

That is the honest type β€” "something was thrown, and I have proved nothing about it" β€” and the safe one, because the compiler forces a check before you use it. Two related facts: an async function never throws synchronously (it returns a rejected promise), and await is what turns that rejection back into a catch-able exception at the call site.

Narrowing what you caught

The first move in every catch is a narrowing check, and instanceof Error does most of the work, unlocking name, message and stack, and since ES2022 also cause β€” the standard way to chain a low-level failure onto the higher-level one you report, as in throw new Error("Cannot start: configuration is unusable", { cause: error }).

export function describeError(error: unknown): string {
  if (!(error instanceof Error)) return `Non-error thrown: ${String(error)}`;
  const cause = error.cause === undefined ? "" : ` (caused by ${describeError(error.cause)})`;
  return `${error.name}: ${error.message}${cause}`;
}

Libraries reject with shapes of their own, so narrow to the field you need:

Source What arrives What to narrow on
node:fs/promises an ErrnoException error.code ("ENOENT", "EACCES") and error.path
fetch a TypeError whose message is fetch failed error.cause, holding the network error and its code, such as "ENOTFOUND"
zod a ZodError error.issues, an array of { path, message }

instanceof Error tells you it is an error, not which error it is, so for the file system case you add a predicate β€” function isErrnoError(error: unknown): error is NodeJS.ErrnoException β€” that checks instanceof Error and then that code is a string. Reading a ZodError properly belongs to Lesson 23; the habit to keep is that every library has its own vocabulary and you check for it explicitly.

Your own errors: a class and a code

A class gives a failure a name you can branch on; a code gives it a value a log aggregator can group and a caller can switch over. Keep both on one class, and make every layer below throw that single type:

// src/errors/domainError.ts
export type ErrorCode = "CONFIG_INVALID" | "API_UNREACHABLE" | "API_SCHEMA" | "STORAGE_FAILED";
export class DomainError extends Error {
  constructor(
    readonly code: ErrorCode,
    message: string,
    readonly context: Readonly<Record<string, unknown>> = {},
    options: { cause?: unknown } = {},
  ) {
    super(message, { cause: options.cause });
    this.name = "DomainError";
  }
}

Set this.name explicitly, because it is what appears in the stack trace header, and note that the parameter properties from Lesson 13 keep the whole class in a dozen lines. A converter turns anything a catch can hold into that one type, so code above the boundary never has to guess:

export function toDomainError(error: unknown, fallback: ErrorCode): DomainError {
  if (error instanceof DomainError) return error;
  const message = error instanceof Error ? error.message : `Non-error thrown: ${String(error)}`;
  return new DomainError(fallback, message, {}, { cause: error });
}

Call it at the edge of each module β€” a fetch wrapper, a file reader, a parser β€” and everything above deals in DomainError: one code to branch on, one message written once, and a cause chain that still reaches the original failure.

Fail fast, or recover?

Two questions settle the policy for any failure: would trying again change the outcome? and is the program in a coherent state if it continues?

Failure Retry? Policy
Missing or unusable configuration at startup No Fail fast: log it, exit non-zero, before any work happens
Network timeout, connection reset, HTTP 429 or 5xx Yes Retry with backoff, as Lesson 21 shows
HTTP 401, 403 or 404 No Fail fast: the credentials or the URL are wrong
A response that fails schema validation No Fail fast for that source; it fails identically every time

Configuration is the clearest fail-fast case β€” readConfig(env) throws new DomainError("CONFIG_INVALID", "PVGIS_BASE_URL is required", { variable: "PVGIS_BASE_URL" }) rather than returning half-built settings. The entry point then treats the two kinds differently: the fatal one escapes to the top and becomes an exit code, while a transient one is handled where it happens and never reaches the caller.

The Result pattern for failures you expect

Some failures are not exceptional at all β€” a column missing from a household's CSV will happen again next month. Making that a thrown exception puts a try/catch at every call site and hides a real outcome from the signature. Model it in the type instead:

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

export const ok = <T, E = never>(value: T): Result<T, E> => ({ ok: true, value });
export const err = <E, T = never>(error: E): Result<T, E> => ({ ok: false, error });

The ok field is the discriminant, so if (!result.ok) narrows to the error half with result.error typed, and the success path gets result.value with no cast. A parser is the natural home for it:

export function parseYieldRow(line: string, lineNumber: number): Result<DailyYield, DomainError> {
  const [date, yieldKwh] = line.split(",");
  if (date === undefined || yieldKwh === undefined) return err(new DomainError("API_SCHEMA", `missing column on line ${lineNumber}`));
  const value = Number(yieldKwh);
  if (!Number.isFinite(value)) return err(new DomainError("API_SCHEMA", `not a number: "${yieldKwh}"`, { lineNumber }));
  return ok({ date, yieldKwh: value });
}

The caller then handles both halves without a try in sight, and the failures stay inspectable instead of aborting the batch: if (!parsed.ok) rejects.push(parsed.error); else rows.push(parsed.value);. Throw for bugs and impossible states; return a Result for failures the caller is expected to decide about.

Safety nets, and logging you can act on

Node offers two global handlers, and both are a place to write one last line and exit, not a way to keep a damaged process alive:

process.on("uncaughtException", (error) => {
  logError(toDomainError(error, "STORAGE_FAILED"));
  process.exit(1); // the in-memory state is unknown from here on
});
process.on("unhandledRejection", (reason) => {
  logError(toDomainError(reason, "STORAGE_FAILED"));
  process.exit(1);
});

[!WARNING] Registering these handlers suppresses Node's default crash, and a promise nobody awaited has already left your program in a state no one designed. Treat every occurrence as a bug report: log it, exit non-zero, and fix the cause.

A useful log line answers five questions β€” what (code), why (message), where (stack), with what data (context), and which run it belonged to (correlationId, generated once and repeated on every line):

export function logError(error: DomainError, correlationId: string): void {
  console.error(JSON.stringify({ level: "error", code: error.code, message: error.message,
    context: error.context, correlationId, stack: error.stack }));
}

Context is where the temptation to over-share lives: log the shape of the data, such as { region: "Saint-Louis", days: 31 }, never the API key, the request body, or a household's name and address. Logs are copied into dashboards, tickets and chat, and a secret logged once is a secret disclosed.


Common mistakes

Mistake What happens The fix
catch (error: any) The checker goes silent exactly where an unknown value arrives Keep unknown and narrow it with instanceof
Reading error.message straight from the catch variable TS18046, or undefined at runtime when a string was thrown Narrow to Error, or to the library's shape, first
throw "configuration is missing" No stack, no instanceof Error, nowhere to put a code Always throw an Error subclass
Swallowing the failure with an empty catch It resurfaces hours later as a wrong result somewhere else Log it, or rethrow it with { cause }
Retrying a validation failure Three identical failures and three times the wait Retry only transient failures; fail fast on schema errors
Logging the whole error and the request body Secrets and personal data end up in dashboards and tickets Log code, message, context and correlationId

Try it

  1. Create src/errors/domainError.ts holding ErrorCode, DomainError, toDomainError and logError from this lesson, all exported, then src/index.ts with the DailyYield interface, Result with ok and err, and parseYieldRow. Run it over three lines β€” one good, one with a non-numeric yield, one missing a column:
import { DomainError } from "./errors/domainError.js";
const lines = ["2026-09-01,4.21", "2026-09-02,nine", "broken-row"];
const rows: string[] = [];
const rejects: DomainError[] = [];
for (const [index, line] of lines.entries()) {
  const parsed = parseYieldRow(line, index + 1);
  if (parsed.ok) rows.push(`${parsed.value.date} ${parsed.value.yieldKwh}`);
  else rejects.push(parsed.error);
}
console.log(`${rows.length} accepted, ${rejects.length} rejected`);
for (const reject of rejects) console.log(`${reject.code}: ${reject.message}`);
  1. Compile and run with cd ~/dev/ts-zero-to-hero && npx tsc && node dist/index.js.

Expected output: 1 accepted, 2 rejected, then API_SCHEMA: not a number: "nine" and API_SCHEMA: missing column on line 3.

  1. Add the fail-fast half: write readConfig(env) so it throws DomainError("CONFIG_INVALID", …) when PVGIS_BASE_URL is missing, call it in a try, and on failure print it through toDomainError and logError and set process.exitCode = 1 instead of parsing. Run node dist/index.js β€” one JSON line with "code":"CONFIG_INVALID", and echo $? reports 1 β€” then PVGIS_BASE_URL="https://example.invalid/api" node dist/index.js, which prints the parse results and exits 0.

Key takeaways

  • A caught value is unknown: narrow it with instanceof Error, or with the shape the library actually promises, before reading anything from it.
  • Chain failures with { cause: error } so the original stack survives, and always set name on your own error classes.
  • Fail fast when retrying cannot help β€” configuration, schema, credentials β€” and retry only what is transient.
  • Use a Result for failures the caller must decide about, throw for bugs and impossible states, and log code, message, context and a correlationId β€” never secrets or personal data, and never as a substitute for fixing the bug.

Check yourself

  1. Why is the variable in a catch block typed unknown rather than Error, and what can legally be thrown in JavaScript?
  2. readFile fails. What do you narrow on to tell ENOENT apart from EACCES, and why is instanceof Error not enough on its own?
  3. Which of these fail fast and which are retried: a missing API key, an HTTP 503, a payload missing a required field, an HTTP 401?
  4. Give one failure in the solar tracker where a Result is the right shape and one where a throw is, and say why in each case.

Next

Lesson 21 β€” Concurrency β€” Parallel Work, Timeouts and Cancellation