Declarative Exception Handling
In this article we will explore a potential remedy to the nightmare that is error handling in JS.
The State of Error Handling in JS
If you have a function that might fail, you would probably do something like this.
let let result: any
result;
try {
const const user: {
name: string;
}
user = const unsafe: () => {
name: string;
}
unsafe();
let result: any
result = const user: {
name: string;
}
user.name: string
name;
} catch(var e: unknown
e) {
let result: any
result = null;
}
This very common implementation has a bug though. It handles all exceptions, not just the ones we expect to happen during normal operation. If unsafe
has a Syntax error in it’s implementation this would silently swallow it. We don’t want that.
A better implementation would be to throw custom error-types for all your expected exceptions and test anything that’s thrown against those.
class class CustomException1
CustomException1 extends var Error: ErrorConstructor
Error {}
class class CustomException2
CustomException2 extends var Error: ErrorConstructor
Error {}
let let result: any
result;
try {
const const user: {
name: string;
}
user = const unsafe: () => {
name: string;
}
unsafe(); //throws CustomException1 & 2
let result: any
result = const user: {
name: string;
}
user.name: string
name;
} catch(var e: unknown
e) {
if(var e: unknown
e instanceof class CustomException1
CustomException1) let result: any
result = null;
else if (var e: unknown
e instanceof class CustomException2
CustomException2) {
var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)
The `console.warn()` function is an alias for
{@link
error
}
.warn("CustomeException2");
let result: any
result = null;
}
else throw var e: unknown
e;
}
But the code here gets really really ugly really really fast. We have to imperatively check which Execution path we should take, opening up the door to many silly bugs.
What we want
Wouldn’t it be really nice if we could declaratively define each execution path an the right thing just happened? Other languages like Rust would make this pretty easy using Errors-As-Values and match
statements. Something like this:
let result = match unsafe_fn() {
Ok(user) => user.name,
Err(CustomExceptions::1) => null,
Err(CustomExceptions::2) => {
print!("CustomeException2");
null;
},
Err(error) => panic!("Unexpected Error: {:?}", error),
};
This way we can declaratively define each possible execution branch, drastically reducing the chance of bugs.
ResultMatcher
a potential solution
I took a stab at implementing a similar API in JS, and I came up with the ResultMatcher
class. You can find the full source code at the bottom of this article. It is used like this:
const const result: void
result = new constructor ResultMatcher(data: any): ResultMatcher
ResultMatcher(const unsafe: () => {
name: string;
}
unsafe)
.ResultMatcher.ok(cb: (user: {
name: string;
}) => any): ResultMatcher
ok(user: {
name: string;
}
user => user: {
name: string;
}
user.name: string
name)
.ResultMatcher.catch<CustomException1>(prototype: (new () => CustomException1) | {
prototype: CustomException1;
}, cb: (instance: CustomException1) => any): ResultMatcher
catch(class CustomException1
CustomException1, e: CustomException1
e => null)
.ResultMatcher.catch<CustomException2>(prototype: (new () => CustomException2) | {
prototype: CustomException2;
}, cb: (instance: CustomException2) => any): ResultMatcher
catch(class CustomException2
CustomException2, e: CustomException2
e => { var console: Console
The `console` module provides a simple debugging console that is similar to the
JavaScript console mechanism provided by web browsers.
The module exports two specific components:
* A `Console` class with methods such as `console.log()`, `console.error()` and `console.warn()` that can be used to write to any Node.js stream.
* A global `console` instance configured to write to [`process.stdout`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstdout) and
[`process.stderr`](https://nodejs.org/docs/latest-v22.x/api/process.html#processstderr). The global `console` can be used without importing the `node:console` module.
_**Warning**_: The global console object's methods are neither consistently
synchronous like the browser APIs they resemble, nor are they consistently
asynchronous like all other Node.js streams. See the [`note on process I/O`](https://nodejs.org/docs/latest-v22.x/api/process.html#a-note-on-process-io) for
more information.
Example using the global `console`:
```js
console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr
```
Example using the `Console` class:
```js
const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
```console.Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)
The `console.warn()` function is an alias for
{@link
error
}
.warn("CustomException2"); return null})
.ResultMatcher.run(): void
run()
It is fully typesafe making it a breeze to work with.
Let’s take a look at each part:
const result
will be the return value of whatever execution branch is taken. In the snippet above the return type would bestring | null
ResultMatcher(unsafe)
constructs a matcher instance for the functionunsafe
.ok()
takes a callback that handles the return value ofunsafe
if it succeeds. If.ok
is not used on the Matcher it will default to the identity function..catch(CustomException1, e => null)
Will only run ifunsafe
throws aCustomException1
. It may return a value..run()
Actually callsunsafe
. If unsafe takes args, you will pass them here (Eg:run("Hello", {option: "a"})
). TS will enforce this.
Sometimes you do want to react to all errors that are thrown. Maybe just to log them. For that we have the catchAll
method.
.catchAll(e => {console.error(e); throw e})
The Snippet
/**
* The configuration for a ResultMatcher Strategy.
*
* @template Prototype
* @template ReturnType
* @typedef {{
* prototype: { new (): Prototype; } | { prototype: Prototype; },
* handler: (instance: Prototype) => ReturnType;
* }} Strategy
*/
/**
* Declaratively define what should happen for all the possible outcomes of a function.
* This follows an immutable builder pattern, so each method returns a new instance of the ResultMatcher class.
*
* @template {(...args: any) => any} UnsafeFunc
* @template {(result: ReturnType<UnsafeFunc>) => any} [SuccessHandler=((result: ReturnType<UnsafeFunc>) => ReturnType<UnsafeFunc>)]
* @template {Strategy<any, any>[]} [Strategies=[]]
* @template {((e: unknown) => any)} [FallbackHandler=(e: unknown) => never]
*/
export class ResultMatcher {
/** @type {UnsafeFunc} */
#unsafeFunction;
/** @type {Strategies} */
#strategies;
/** @type {SuccessHandler} */
#successHandler;
/** @type {FallbackHandler} */
#fallbackHandler;
/**
* @param {UnsafeFunc} func
* @param {Strategies} strategies
* @param {SuccessHandler} successHandler
* @param {FallbackHandler} fallbackHandler
*/
constructor(
func,
strategies = /** @type {any} */ ([]),
successHandler = /** @type {any} */ (identity),
fallbackHandler = /** @type {any} */ (raise),
) {
this.#unsafeFunction = func;
this.#strategies = strategies;
this.#successHandler = successHandler;
this.#fallbackHandler = fallbackHandler;
}
/**
* Defines a strategy for a given error type.
*
* @template Prototype
* @template StrategyReturnType
*
* @param {{ new (): Prototype;} | { prototype: Prototype; }} prototype - The error type to handle. Thrown things will be compared against this with `instanceof`.
* @param {(instance: Prototype) => StrategyReturnType} handler - Callback to handle the error.
* @returns {ResultMatcher<UnsafeFunc, SuccessHandler, [...Strategies, Strategy<Prototype, StrategyReturnType>], FallbackHandler>}
*/
catch(prototype, handler) {
const registeredStrategy = { prototype, handler };
return new ResultMatcher(
this.#unsafeFunction,
[...this.#strategies, registeredStrategy],
this.#successHandler,
this.#fallbackHandler,
);
}
/**
* @template {(e:unknown) => any} Handler
*
* @param {Handler} handler
* @returns {ResultMatcher<UnsafeFunc, SuccessHandler, Strategies, Handler>}
*/
catchAll(handler) {
return new ResultMatcher(
this.#unsafeFunction,
this.#strategies,
this.#successHandler,
handler,
);
}
/**
* Handle the happy path
*
* @template {(result: ReturnType<UnsafeFunc>) => any} Handler
* @param {Handler} handler
* @returns {ResultMatcher<UnsafeFunc, Handler, Strategies, FallbackHandler>}
*/
ok(handler) {
return new ResultMatcher(
this.#unsafeFunction,
this.#strategies,
handler,
this.#fallbackHandler,
);
}
/**
* Calls the unsafe function with the given parameters and handles any errors that may be thrown
* according to the registered strategies.
*
* @param {Parameters<UnsafeFunc>} params
* @returns {ReturnType<SuccessHandler> | ReturnType<Strategies[number]["handler"]> | ReturnType<FallbackHandler>}
*/
run(...params) {
let successResult;
try {
// @ts-ignore
successResult = this.#unsafeFunction(...params);
} catch (e) {
for (const strategy of this.#strategies) {
if (e instanceof /** @type {any} */ (strategy.prototype)) {
return strategy.handler(e);
}
}
return this.#fallbackHandler(e);
}
return this.#successHandler(successResult);
}
}
/**
* The identity function
* @template T
* @param {T} x
* @returns {T}
*/
const identity = (x) => x;
/**
* @template T
* @param {T} e
* @returns {never}
* @throws {T}
*/
const raise = (e) => {
throw e;
};