bastianplsfix

Errors and exceptions

throw abandons the current work and unwinds outward, leaving every construct it is inside, until it reaches a try. Execution continues in that try's catch.

The reason to want that: the place that discovers a file is missing is rarely the place that knows what to do about it. openFile knows the file is not there; only the loop three levels up knows whether to skip it, retry, or stop everything. The cost is that nothing records the arrangement. A function's type says what it returns and says nothing about what it can throw, so at the catch site you are holding a value of unknown provenance, and TypeScript's one contribution here is to make you say that out loud.

Create programs/errors-and-exceptions.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:

import {
assert,
assertEquals,
assertFalse,
assertStrictEquals,
assertThrows,
} from "@std/assert";

Follow the page as you add and revise the runnable examples below that import.

the unwinding

Deno.test("the unwinding", () => {
function openFile(path: string): string {
if (path === "missing.json") throw new Error(`no such file: ${path}`);
return `contents of ${path}`;
}
function readOne(path: string): string {
return openFile(path);
}

const results: string[] = [];
for (const path of ["a.json", "missing.json", "b.json"]) {
try {
results.push(readOne(path));
} catch (error) {
assert(error instanceof Error);
results.push(`skipped ${path}: ${error.message}`);
}
}

assertEquals(results, [
"contents of a.json",
"skipped missing.json: no such file: missing.json",
"contents of b.json",
]);
});
Check programs/errors-and-exceptions.test.ts
running 1 test from ./programs/errors-and-exceptions.test.ts
the unwinding ... ok (0ms)

ok | 1 passed | 0 failed (1ms)

When the throw fires, four constructs are active: the loop, the try, readOne, and the if inside openFile. The throw leaves all of them in order and stops at the try, and nothing in between had to know this was going to happen, which is both the point and the danger. The loop carries on afterwards: handling the failure at the level that can skip a file is what makes the other two files still get read.

The statement is throw «value» and it accepts any value at all. What you should put there is a design question with its own page, designing error types; the short answer is to throw an Error. The three shapes are worth one sentence each: try-catch handles a failure, try-finally cleans up and lets the failure continue outward, try-catch-finally does both, and a try on its own is not a statement.

finally runs on every way out

Three functions, three different exits from a try. Predict what the log holds after the return from inside one:

Deno.test("finally runs on every way out", () => {
const ran: string[] = [];

function normal(): void {
try {
ran.push("body");
} finally {
ran.push("after the body");
}
}
normal();

function thrower(): void {
try {
throw new Error("out");
} finally {
ran.push("after a throw");
}
}
assertThrows(() => thrower(), Error, "out");

function early(): string {
try {
return "value";
} finally {
ran.push("after a return");
}
}
assertStrictEquals(early(), "value");

assertEquals(ran, [
"body",
"after the body",
"after a throw",
]);
});
Check programs/errors-and-exceptions.test.ts
running 2 tests from ./programs/errors-and-exceptions.test.ts
the unwinding ... ok (0ms)
finally runs on every way out ... FAILED (8ms)

ERRORS

finally runs on every way out => ./programs/errors-and-exceptions.test.ts:36:11
error: AssertionError: Values are not equal.

[Diff] Actual / Expected

[
"body",
"after the body",
"after a throw",
- "after a return",
]

FAILURES

finally runs on every way out => ./programs/errors-and-exceptions.test.ts:36:11

FAILED | 1 passed | 1 failed (9ms)

error: Test failed

The finally ran anyway. Three exits, three runs: reaching the end of the block, throwing out of it, and returning from inside it, and the last is the one that surprises people. The return value is computed, then the finally runs, and only then does the function actually return, which is why early() still handed back "value" while the log grew. Correct the prediction to include "after a return":

finally runs on every way out ... ok (0ms)

omitting the binding

Deno.test("omitting the binding", () => {
function throws(work: () => void): boolean {
try {
work();
} catch {
return true;
}
return false;
}

assert(throws(() => {
throw new Error("any failure");
}));
assertFalse(throws(() => {}));
});
omitting the binding ... ok (0ms)

Since ES2019 the catch binding is optional, for when the fact of a failure is all you need. This is roughly how an assertion library's assertThrows, the helper this whole series leans on, begins life, although a real one also checks what was thrown, which belongs to the assertions reference.

the caught value is unknown

Catch an error you threw yourself, one line up, and try to read its message:

Deno.test("the caught value is unknown", () => {
try {
throw new Error("boom");
} catch (error) {
assertStrictEquals(error.message, "boom");
}
});
Check programs/errors-and-exceptions.test.ts
TS18046 [ERROR]: 'error' is of type 'unknown'.
assertStrictEquals(error.message, "boom");
~~~~~
at file:///programs/errors-and-exceptions.test.ts:94:26

error: Type checking failed.

TS18046. Deno's defaults include strict, which turns on useUnknownInCatchVariables, so a caught value arrives as the unknown of the any, unknown, and never page rather than any. This is the right default, and the reason is the answer above: since nothing in any signature says what a function throws, the checker genuinely does not know what you have, and unknown is it declining to guess. Nor can you declare your way past it, which the second pin below records:

Deno.test("the caught value is unknown", () => {
try {
throw new Error("boom");
} catch (error) {
// @ts-expect-error: 'error' is of type 'unknown'.
void error.message;

assert(error instanceof Error);
assertStrictEquals(error.message, "boom");
}

try {
throw new Error("boom");
// @ts-expect-error: Catch clause variable type annotation must be 'any' or 'unknown' if specified.
} catch (error: Error) {
assertStrictEquals(error.message, "boom");
}
});
the caught value is unknown ... ok (0ms)

TS1196 names the only two annotations a catch binding accepts: unknown is what you get anyway, and any is a decision to stop being helped. The narrowed read in the first block is the sanctioned route, and the next steps measure why the narrowing has to be a real check.

any value can be thrown

Deno.test("any value can be thrown", () => {
let caught: unknown;
try {
throw "just a string";
} catch (error) {
caught = error;
}

assertStrictEquals(typeof caught, "string");
assertFalse(caught instanceof Error);
});
any value can be thrown ... ok (0ms)

deno check has no objection, and neither does the recommended lint set: no-throw-literal exists and is not among Deno's recommended rules, so a thrown string is legal, checked, linted code. What it costs becomes visible when nobody catches it. Put one line in programs/bare-string.ts and run it:

throw "just a string";
error: Uncaught (in promise) "just a string"

One line: no stack trace, no file, no line number, because a string carries none of that. Swap the same file's line for throw new Error("just a string") and run again:

error: Uncaught (in promise) Error: just a string
throw new Error("just a string");
^
at file:///programs/bare-error.ts:1:7

The Error recorded where the throw was. That is the whole argument for throwing Error instances, and a reason to turn no-throw-literal on. Delete the scratch files before moving on.

narrowing what you caught

Deno.test("narrowing what you caught", () => {
function toError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(
`thrown value was not an Error: ${Deno.inspect(value)}`,
);
}

assertStrictEquals(toError(new RangeError("bad")).name, "RangeError");

const wrapped = toError("just a string");
assertStrictEquals(wrapped.name, "Error");
assert(wrapped.message.includes('"just a string"'));
});
narrowing what you caught ... ok (0ms)

instanceof Error is the first move, and it is genuine narrowing rather than a cast, from the unions and narrowing page's the checks that narrow are ordinary JavaScript. What it is not is a guarantee about the world, because the previous step proved the thrown value might be a string, and it is weaker for your own error classes than for built-in ones, which the designing error types page demonstrates in what survives a boundary. The shape that works is one toError at the boundary: past that call, every path is holding an Error and the unknown has stopped spreading. Note Deno.inspect rather than String(value) in the message: a thrown object would otherwise arrive as [object Object], which is the one thing less useful than no information, and the inspector itself belongs to the console reference.

finally can discard the exception

alwaysDefault parses JSON with a fallback, spelled the tempting way. Predict what the good input returns:

Deno.test("finally can discard the exception", () => {
function alwaysDefault(text: string): string {
try {
return JSON.parse(text) as string;
} finally {
return "default";
}
}

assertStrictEquals(alwaysDefault('"hello"'), "hello");
assertStrictEquals(alwaysDefault("not json"), "default");
});
Check programs/errors-and-exceptions.test.ts
running 7 tests from ./programs/errors-and-exceptions.test.ts
...
finally can discard the exception ... FAILED (8ms)

ERRORS

finally can discard the exception => ./programs/errors-and-exceptions.test.ts:136:11
error: AssertionError: Values are not strictly equal.

[Diff] Actual / Expected

- default
+ hello

FAILURES

finally can discard the exception => ./programs/errors-and-exceptions.test.ts:136:11

FAILED | 6 passed | 1 failed (10ms)

error: Test failed

The first call loses a perfectly good return value, and the second call loses an exception: JSON.parse threw, the finally ran, and its return replaced the in-flight throw with an ordinary return, so a caller sees no failure at all. finally always gets the last word, which is exactly what makes it reliable for cleanup and dangerous for anything else, and throw, break, and continue in there do the same thing a return does. The type checker had no objection; run deno lint on the failing file and the linter does:

error[no-unsafe-finally]: Unsafe usage of return statement
--> programs/errors-and-exceptions.test.ts:141:9
|
141 | return "default";
| ^^^^^^^^^^^^^^^^^
= hint: Use of the control flow statements (`return`, `throw`, `break` and `continue`) in a `finally` blockwill most likely lead to undesired behavior.

docs: https://docs.deno.com/lint/rules/no-unsafe-finally

no-unsafe-finally is in the recommended set, and its hint names all four offenders. This is the third time the same division has come up in these entries, after if (x = 1) on the assignment page and switch fallthrough on the branching page, so it is worth stating plainly: the checker asks whether the program is consistent, the linter asks whether you meant it, and discarding an exception is perfectly consistent. Its sibling by omission, the empty catch block, is reported by no-empty, also recommended. Correct the prediction to "default" and sign for the exhibit:

Deno.test("finally can discard the exception", () => {
function alwaysDefault(text: string): string {
try {
return JSON.parse(text) as string;
} finally {
// deno-lint-ignore no-unsafe-finally
return "default";
}
}

assertStrictEquals(alwaysDefault('"hello"'), "default");
assertStrictEquals(alwaysDefault("not json"), "default");
});
finally can discard the exception ... ok (1ms)

catching is a decision

Deno.test("catching is a decision", () => {
function withFallback(work: () => string): string {
try {
return work();
} catch (error) {
if (!(error instanceof RangeError)) throw error;
return "fallback";
}
}

assertStrictEquals(withFallback(() => "fine"), "fine");
assertStrictEquals(
withFallback(() => {
throw new RangeError("too big");
}),
"fallback",
);

assertThrows(
() =>
withFallback(() => {
throw new TypeError("not ours");
}),
TypeError,
"not ours",
);
});
catching is a decision ... ok (0ms)

Three outcomes, and only the middle one is handled here: no failure, a failure this function understands, and a failure it does not. The last is rethrown unchanged, which preserves the original stack and lets a caller that does understand it take its turn, which the pinned TypeError proves happened. A catch that handles everything is almost always wrong, because everything includes the bugs you have not found yet. Narrow first, decide second, and rethrow what is not yours.

the stack

Deno.test("the stack", () => {
function inner(): never {
throw new Error("deep");
}
function outer(): void {
inner();
}

const error = assertThrows(() => outer(), Error, "deep");
const lines = (error.stack ?? "").split("\n");

assertStrictEquals(lines[0], "Error: deep");
assert(lines[1].trimStart().startsWith("at inner"));
assert(lines[2].trimStart().startsWith("at outer"));
});
the stack ... ok (1ms)

.stack is not in the ECMAScript standard, is supported everywhere anyway, and has a shape that varies by engine. Deno's is V8's: a first line of name: message, then one at frame per active call, innermost first, and the names in those frames are the ones the functions page's where a name comes from argued for. Its type is string | undefined, which is what the ?? is for. Read it from the top for where the error was made, and from the bottom for where the work started. V8 also provides Error.captureStackTrace for trimming frames off the top, which is worth knowing exists and is rarely worth using.

acquire the resource before the try

Deno.test("acquire the resource before the try", () => {
const closed: string[] = [];
function open(name: string): { name: string; close(): void } {
return {
name,
close() {
closed.push(name);
},
};
}

const handle = open("good");
try {
assertStrictEquals(handle.name, "good");
} finally {
handle.close();
}

assertEquals(closed, ["good"]);
});
acquire the resource before the try ... ok (0ms)

With the open inside the try, a failure in open would run a finally that closes something which was never opened. Outside, the finally only ever sees a handle that exists. This is the shape finally is actually for, and the one use the previous steps left standing.

In practice

Related