Error Handling Strategies
Throwing versus returning a result, typed error unions, keeping error information across boundaries, and choosing one approach so callers do not have to guess.
Throwing versus returning a result, typed error unions, keeping error information across boundaries, and choosing one approach so callers do not have to guess.
A payment function throws four different errors. The caller
catches all of them the same way, logs error.message, and
shows the user "Something went wrong" — because the signature
says Promise<Receipt> and there was nowhere to find out what
else it might do.
A function's return type is checked. What it throws is not
mentioned anywhere. By the end of this lesson you will know when
to throw and when to return, what a Result type costs and when
it pays, and how to keep failure information intact across the
boundaries it has to cross.
function loadPhoto(id: string): Promise<Photo> { ... }That signature says a Photo comes back. It does not say the
function throws NotFoundError, PermissionError or
NetworkError, and nothing makes a caller handle any of them.
There is no throws clause here, and no plan to add one.
So callers discover failures by reading the implementation, or in production. That is the trade-off underlying everything in this lesson: exceptions are invisible, and return types are enforced.
Exceptions are still right for a great deal. They travel automatically through intermediate frames that cannot do anything useful, they carry a stack, and they do not clutter every signature between the failure and the handler.
The question that decides it: is this failure part of the normal operation of this function?
function findPhoto(id: string): Photo | undefined; // normal
function loadPhoto(id: string): Promise<Photo>; // exceptional
function parseDate(text: string): Date | null; // normal
async function connect(): Promise<Connection>; // exceptionalA lookup that finds nothing is an ordinary outcome, and the
checker forces the caller to deal with undefined. A disk that
cannot be read while you are trying to read it is exceptional.
Three practical rules follow.
Expected failures belong in the return type
Not found, invalid input, a rule not satisfied — anything a caller will routinely encounter. Put it in the signature and the checker makes them deal with it.
Unexpected failures should throw
Out of disk, network down, a bug. Most callers can do nothing useful, and burdening every signature between them and the one place that can help nobody.
Never signal failure with real-looking data
-1, 0 and "" let a caller who forgot to check carry on
with plausible nonsense. null and undefined are safe
precisely because using them fails loudly.
When a failure is expected and carries information, encode it:
That is the discriminated union from the foundations course. The
checker will not let you reach result.value without checking
ok, and the error is a union you can switch on exhaustively —
so a new failure kind produces a compile error at every place
that handles them.
Compare with an exception: the caller sees a Photo in the
signature, catches unknown, and narrows with instanceof
against classes they had to know existed.
Bad — a Result for everything.
Good — Result where failure
is real, exceptions elsewhere.
The first version pays the cost with no benefit. getName
cannot fail — Result<string, never> says so — and every call
still needs unwrapping. Three unwraps in five lines is what
makes people abandon Result entirely, and the abandonment is
usually blamed on the pattern rather than on applying it to
functions that never fail.
Result has no automatic propagation here — there is no ?
operator, no do notation. Every layer unwraps by hand. That
makes it excellent at a boundary where failures are the point,
and tiring through a deep call stack.
The practical division that works:
Exceptions.
Failures here really are exceptional, and the call stack is deep enough that unwrapping at every layer costs more than it proves.
Result.
A public API, a form, a request handler. The caller has to handle failure, so the signature should say so rather than hoping they read the documentation.
Convert at the edge:
Whichever style, the failure has to survive being passed on.
cause keeps the original in the printed output. Without it you
have replaced the specific failure with a summary, which is the
most common way debugging information is lost.
The same for Result:
And the rule that matters at boundaries: the message a user sees and the message you log are different messages.
Internal details in a user-facing error leak paths, query fragments and sometimes credentials. A summary in a log is useless at 3am. Produce both.
Three boundaries need a deliberate policy rather than a habit.
Validation of incoming data. Return a Result with the
field-level detail — the caller needs to tell a user what to
fix, and an exception flattens that.
Network calls. A timeout, a 500 and a 404 are different
outcomes with different responses, and none of them should be a
generic thrown Error if the caller can act on the difference.
Background work. Nobody is waiting, so failure must be recorded rather than propagated. Catch, log with context, update whatever tracks status, and never let a rejection escape into nothing.
For anything retried, the classification from the AI course
applies unchanged: can doing this again produce a different
result? Retrying a 400 five times gives five failures.
You can now choose deliberately between a failure the checker
enforces and one that travels invisibly, and you know what each
costs. The division that works in practice — exceptions inside,
Result at boundaries — avoids both the unhandled-throw problem
and the unwrapping fatigue that makes teams give up on
Result.
Next is Validating Data at the Boundary, which is the
boundary this lesson kept referring to. Everything crossing into
your program is unknown until proven otherwise, and a type
annotation is a claim rather than a check — the single most
important limitation of the type system.
Before you move on, take a function that throws and write down every error it can produce, including from what it calls. Then look at one caller and check how many it handles. That list is what the signature was not telling anyone.
THE ASYMMETRY
a return type is CHECKED; what a function throws is INVISIBLE
there is no `throws` clause in this language
WHICH FAILURES GO WHERE
is this failure part of NORMAL operation?
expected -> the return type: T | undefined, or Result
unexpected -> throw: disk, network, bugs
never -> a sentinel that could be real data (-1, 0, "")
RESULT
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
the checker forbids reaching .value without checking .ok
the error can be a union, switched on exhaustively
a new failure kind becomes a compile error everywhere
cost: no automatic propagation - every layer unwraps by hand
Result<T, never> on a function that cannot fail is pure cost
WHERE EACH FITS
inside a module exceptions
at a boundary Result - public API, forms, request handlers
convert at the edge with try/catch
KEEPING INFORMATION
throw new MyError(msg, { cause: error }) keeps the original
a Result error can carry a cause too
log everything; show the user a summary
internal detail in a user-facing message leaks paths and secrets
ASYNC
an unawaited rejection does not travel up - it is unhandled
await, .catch, or void - and turn on no-floating-promises
BOUNDARIES WITH A POLICY
validation Result with field-level detail
network distinguish timeout / 4xx / 5xx
background catch, log with context, record status
retries only what a repeat could changetype Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };type ValidationError =
| { kind: "too-large"; maxBytes: number }
| { kind: "wrong-format"; found: string };
function validate(file: File): Result<Photo, ValidationError> {
if (file.size > MAX) {
return { ok: false, error: { kind: "too-large", maxBytes: MAX } };
}
return { ok: true, value: toPhoto(file) };
}const result = validate(file);
if (!result.ok) {
switch (result.error.kind) {
case "too-large":
return `Too large. Maximum is ${result.error.maxBytes} bytes.`;
case "wrong-format":
return `Cannot read ${result.error.found} files.`;
}
}
upload(result.value);function getName(photo: Photo): Result<string, never> {
return { ok: true, value: photo.name };
}
const nameResult = getName(photo);
if (!nameResult.ok) return nameResult;
const captionResult = makeCaption(nameResult.value);
if (!captionResult.ok) return captionResult;
const sizeResult = getSize(captionResult.value);
if (!sizeResult.ok) return sizeResult;function getName(photo: Photo): string {
return photo.name;
}
const parsed = parseUpload(request); // this one can fail
if (!parsed.ok) return parsed;
const photo = toPhoto(parsed.value); // these cannot
const caption = makeCaption(photo);async function handler(request: Request): Promise<Response> {
try {
const photo = await loadPhoto(request.id); // may throw
return json({ ok: true, value: photo });
} catch (error) {
if (error instanceof NotFoundError) {
return json({ ok: false, error: "not-found" }, 404);
}
throw error;
}
}try {
await writeFile(path, data);
} catch (error) {
throw new UploadError(`could not save ${path}`, { cause: error });
}type ParseError = {
kind: "invalid-json";
message: string;
cause?: unknown;
};catch (error) {
log.error("upload failed", { photoId, error }); // everything
return { ok: false, error: "Could not upload. Please retry." };
}