Loris Sigrist looking very handsome Loris Sigrist

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: anyresult;
try {
  const 
const user: {
    name: string;
}
user
=
const unsafe: () => {
    name: string;
}
unsafe
();
let result: anyresult =
const user: {
    name: string;
}
user
.name: stringname;
} catch(var e: unknowne) { let result: anyresult = 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 CustomException1CustomException1 extends var Error: ErrorConstructorError {}
class class CustomException2CustomException2 extends var Error: ErrorConstructorError {}

let let result: anyresult;
try {
   const 
const user: {
    name: string;
}
user
=
const unsafe: () => {
    name: string;
}
unsafe
(); //throws CustomException1 & 2
let result: anyresult =
const user: {
    name: string;
}
user
.name: stringname;
} catch(var e: unknowne) { if(var e: unknowne instanceof class CustomException1CustomException1) let result: anyresult = null; else if (var e: unknowne instanceof class CustomException2CustomException2) { 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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)
The `console.warn()` function is an alias for {@link error } .
@sincev0.1.100
warn
("CustomeException2");
let result: anyresult = null; } else throw var e: unknowne; }

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: voidresult = new constructor ResultMatcher(data: any): ResultMatcherResultMatcher(
const unsafe: () => {
    name: string;
}
unsafe
)
.
ResultMatcher.ok(cb: (user: {
    name: string;
}) => any): ResultMatcher
ok
(
user: {
    name: string;
}
user
=>
user: {
    name: string;
}
user
.name: stringname)
.
ResultMatcher.catch<CustomException1>(prototype: (new () => CustomException1) | {
    prototype: CustomException1;
}, cb: (instance: CustomException1) => any): ResultMatcher
catch
(class CustomException1CustomException1, e: CustomException1e => null)
.
ResultMatcher.catch<CustomException2>(prototype: (new () => CustomException2) | {
    prototype: CustomException2;
}, cb: (instance: CustomException2) => any): ResultMatcher
catch
(class CustomException2CustomException2, e: CustomException2e => { 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 ```
@see[source](https://github.com/nodejs/node/blob/v22.x/lib/console.js)
console
.Console.warn(message?: any, ...optionalParams: any[]): void (+1 overload)
The `console.warn()` function is an alias for {@link error } .
@sincev0.1.100
warn
("CustomException2"); return null})
.ResultMatcher.run(): voidrun()

It is fully typesafe making it a breeze to work with.

Let’s take a look at each part:

Sometimes you do want to react to all errors that are thrown. Maybe just to log them. For that we have the catchAll method.

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;
};