Errors and Exceptions
throw, try and catch; why a caught value is unknown rather than an Error; defining your own error types; and the empty catch block that turns a bug into a mystery.
throw, try and catch; why a caught value is unknown rather than an Error; defining your own error types; and the empty catch block that turns a bug into a mystery.
A file upload fails. Somewhere in the code is a try block with
an empty catch, written months ago to stop a crash during a
demo. So the upload reports success, the file is not there, and
the only person who will ever know is the customer who comes
back for it.
Things go wrong: a file is missing, a service is down, input is
malformed. By the end of this lesson you will know how failure
travels through a program, why the value you catch is unknown
rather than an Error, and how to define failures a caller can
respond to precisely.
function parseSize(text: string): number {
const value = Number(text);
if (!Number.isFinite(value)) {
throw new Error(`not a number: ${text}`);
}
return value;
}throw stops the function immediately and hands the value
upward, through every caller, until something catches it. If
nothing does, the program stops and prints the error.
try {
const size = parseSize(input);
save(size);
} catch (error) {
console.error("could not parse the size:", error);
}Everything in try runs until something throws. If it does, the
catch block receives the thrown value and execution continues
after the block.
An Error carries three useful things:
const error = new Error("not a number: abc");
error.message; // "not a number: abc"
error.name; // "Error"
error.stack; // where it was thrown, and how it got thereThe stack is what turns "something failed" into a line number. Preserve it — the next sections are largely about not losing it.
Here is the part that surprises people coming from other languages:
catch (error) {
error.message;
// ~~~~~~~
// 'error' is of type 'unknown'.
}The caught value is typed unknown, because anything can be
thrown — a string, a number, an object, undefined. Most code
throws Error objects, and nothing guarantees it. Library code
that throws a plain string exists.
unknown means "you must check before using". So narrow it,
with the mechanism from the unions lesson:
catch (error) {
if (error instanceof Error) {
console.error(error.message);
} else {
console.error("unknown failure:", error);
}
}That is more typing than most languages ask for, and it is honest — it reflects what can actually arrive.
A small helper is worth having once and reusing:
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}A caller who wants to handle one specific failure needs a way to name it:
class UploadError extends Error {
constructor(
message: string,
readonly filename: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = "UploadError";
}
}
class FileTooLargeError extends UploadError {}
class UnsupportedFormatError extends UploadError {}Now instanceof gives callers exactly the precision they want:
try {
await upload(file);
} catch (error) {
if (error instanceof UnsupportedFormatError) {
showFormatHelp(error.filename);
} else if (error instanceof UploadError) {
showRetry(error.filename);
} else {
throw error; // not ours - let it continue
}
}instanceof UnsupportedFormatError
One specific failure, which you can respond to specifically — here, showing help about accepted formats.
instanceof UploadError
Anything from this subsystem. A generic retry is a reasonable answer to all of them.
else throw error
Not ours. Pass it on — catching something you do not handle and swallowing it is how a bug disappears.
Setting this.name matters because it appears in the printed
output. Extra properties — filename here — are how an error
carries the context that ends an investigation.
When you catch a low-level failure and throw your own, the original matters:
try {
await writeFile(path, data);
} catch (error) {
throw new UploadError(`could not save ${path}`, path, {
cause: error,
});
}cause attaches the original. Printing the new error shows both
— your meaningful message and the underlying detail. Without it,
you have replaced the useful specifics with a summary.
Bad — catching everything and carrying on.
for (const file of files) {
try {
await upload(file);
} catch {
// keep going
}
}
console.log("All uploads complete");Good — catching what you planned for, and reporting the rest.
const failed: string[] = [];
for (const file of files) {
try {
await upload(file);
} catch (error) {
if (!(error instanceof UploadError)) throw error;
console.warn(`skipped ${file.name}: ${error.message}`);
failed.push(file.name);
}
}
const ok = files.length - failed.length;
console.log(`${ok} uploaded, ${failed.length} failed`);The first version does what it was written to do — one bad file
does not stop the run. It also swallows the typo that throws a
TypeError on every file, the disk filling up, and the network
being down. It prints "All uploads complete" having uploaded
nothing, and there is no record anywhere of why.
The second handles the failure it understands, says so, counts it, and lets anything unanticipated stop the program — which is the correct outcome, because a loud failure gets fixed and a silent one does not.
The rule: catch what you have a plan for. If your catch block is empty or only logs and continues, you did not have a plan.
const handle = await open(path);
try {
await process(handle);
} finally {
await handle.close(); // runs whether or not it threw
}finally runs on the way out however the block ends — normally,
by throwing, or by returning. It is where cleanup belongs.
Note there is no catch here. That combination is deliberate
and common: this code does not know how to handle the failure,
it only knows what must be tidied up. The error continues
upward to someone who does.
Not every failure deserves an exception.
function findPhoto(name: string): Photo | undefined { ... } // normal
function loadPhoto(path: string): Promise<Photo> { ... } // throwsNot finding something you searched for is an ordinary result,
and undefined says so — the checker then forces the caller to
handle it, which an exception does not. Being unable to read a
file you were told to read is a failure.
The question that decides it: would every caller immediately handle this?
An ordinary "not found".
Not finding something you searched for is a normal result. The checker forces every caller to handle it, which an exception does not.
A real failure that should travel.
Most callers cannot do anything useful and want it to reach someone who can. Being unable to read a file you were told to read is this.
Failure visible in the type.
For operations that fail routinely and where the failure carries information. Costs a check at every call site.
That last one is a discriminated union:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };The checker will not let a caller reach value without checking
ok first. The practice course develops this properly.
THROWING AND CATCHING
throw new Error(`not a number: ${text}`);
try { ... } catch (error) { ... } finally { ... }
finally runs however the block ends - cleanup goes there
never `return` from finally - it discards the error
THE CAUGHT VALUE IS unknown
anything can be thrown, including a string
if (error instanceof Error) { error.message }
a toError(value: unknown): Error helper, written once
YOUR OWN ERRORS
class UploadError extends Error {
constructor(message: string, readonly filename: string,
options?: ErrorOptions) {
super(message, options);
this.name = "UploadError";
}
}
a base class per subsystem, specifics beneath it
gives callers three levels:
instanceof UnsupportedFormatError one failure
instanceof UploadError anything of ours
else throw error not ours - pass it on
extra properties carry the context that ends the investigation
{ cause: error } keeps the original in the printed output
THE RULE
catch what you have a PLAN for
an empty catch, or one that only logs and continues, is not a plan
it reports success having done nothing, with no record of why
always re-throw what you did not handle
THROW OR RETURN
T | undefined an ordinary "not found" - the checker forces
the caller to handle it
throw a real failure that should travel upward
Result<T, E> a discriminated union; failure visible in the
type, at the cost of a check per call
ask: would EVERY caller handle this immediately?Failures can now carry a type, a message and their cause, and
callers can respond to exactly the ones they understand. The
habit worth keeping is the last line of every catch: if you
did not plan for this, throw it on.
Next is Promises and Async Await, where errors get more interesting. Work that finishes later fails later too, and a rejection nobody catches behaves differently from an exception — which is the source of the most common silent failure in asynchronous code.
Before you move on, write a small class extending Error with
one extra property, throw it, and catch it with instanceof.
Then add an empty catch somewhere in a program you have and
watch a real bug disappear without trace. The second experiment
is the one that makes the rule stick.