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
- Create
src/errors/domainError.tsholdingErrorCode,DomainError,toDomainErrorandlogErrorfrom this lesson, all exported, thensrc/index.tswith theDailyYieldinterface,Resultwithokanderr, andparseYieldRow. 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}`);
- 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.
- Add the fail-fast half: write
readConfig(env)so it throwsDomainError("CONFIG_INVALID", β¦)whenPVGIS_BASE_URLis missing, call it in atry, and on failure print it throughtoDomainErrorandlogErrorand setprocess.exitCode = 1instead of parsing. Runnode dist/index.jsβ one JSON line with"code":"CONFIG_INVALID", andecho $?reports1β thenPVGIS_BASE_URL="https://example.invalid/api" node dist/index.js, which prints the parse results and exits0.
Key takeaways
- A caught value is
unknown: narrow it withinstanceof 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 setnameon your own error classes. - Fail fast when retrying cannot help β configuration, schema, credentials β and retry only what is transient.
- Use a
Resultfor failures the caller must decide about,throwfor bugs and impossible states, and logcode,message,contextand acorrelationIdβ never secrets or personal data, and never as a substitute for fixing the bug.
Check yourself
- Why is the variable in a
catchblock typedunknownrather thanError, and what can legally be thrown in JavaScript? readFilefails. What do you narrow on to tellENOENTapart fromEACCES, and why isinstanceof Errornot enough on its own?- 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?
- Give one failure in the solar tracker where a
Resultis the right shape and one where athrowis, and say why in each case.
Next
Lesson 21 β Concurrency β Parallel Work, Timeouts and Cancellation