`Result` Algebraic Data Type Proposal

Hello!

I have for a while been wondering if others would be interested in seeing a new datatype built into ES.

For context, I have worked in teams for a few years now that very much prefer to model errors just like any other value - as opposed to throwing exceptions. Not to say that throwing exceptions has no place, but rather to discern between expected application errors (such as "user not found") vs unexpected application errors (such as "application has lost network connection").

In the case of expected application errors that we'd want to treat just like any other regular value (such as a string, boolean, etc), we'd use a Result datatype that would contain a success value and a failure value.

I have written at length about this already and have a popular npm package that does just this that gets 40,000+ downloads per week.

The thing is that there are many implementations of a Result / Either type already in the ES ecosystem - all which are incompatible with each other obviously and just re-invent the wheel.

So I figured this may as well be built into ES.

3 Likes

Related thread. An Option type is a cousin of the Result Type:

See also, https://es.discourse.group/t/using-maybe-object-to-make-null-undefined-errors-more-semantic/843/1

I would be interested in this sort of thing as well. I've certainly ran into cases where I've needed finer control over my exception handling, and ended up resorting to basically replicating a result type in order to accomplish this. It would be nice to have a built-in one. I always feel bad when I do it though, because it's currently not a "standard" way to accomplish exception handling in JavaScript (as, you always have to resort to some external library, or some hand-crafted module to accomplish this).

2 Likes

Let me try presenting a hypothetical API for a result type, so we have something tangible to discuss. I'll just start by showing it's implementation, since it's pretty simple:

const privateConstructorSentinel = Symbol()

class Result {
  #isResult = true

  constructor(privateConstructorSentinel_, { isOk, value, error }) {
    if (privateConstructorSentinel_ !== privateConstructorSentinel) {
      throw new Error('This constructor is private')
    }

    Object.assign(this, { isOk, value, error })
    Object.freeze(this)
  }

  static ok(value = undefined) {
    return new Result(privateConstructorSentinel, { isOk: true, value, error: null })
  }

  static error(error) {
    return new Result(privateConstructorSentinel,  { isOk: false, value: null, error })
  }

  static unwrap(result) {
    if (!(#isResult in result)) {
      throw new TypeError('You must supply an instance of Result as an argument.')
    }

    if (result.isOk) {
      return result.value
    } else {
      throw new Error('Unwrapped a result object that was in an error state.', { cause: result.error })
    }
  }

  static assertOk(result) {
    Result.unwrap(result)
  }

  static throwReturnedErrors(fn) {
    return function(...args) {
      const result = fn.call(this, ...args)

      if (!(#isResult in result)) {
        throw new TypeError('A function wrapped by throwReturnedErrors returned a value that was not an instance of Result.')
      }

      if (result.isOk) {
        return result.value
      } else {
        throw result.error
      }
    }
  }
}

So, all result-based logic is placed in a class called Result. You can't instantiate it directly, but you can use the ok() and error() static factory functions to create result instances.

> Result.ok('Hi there!')
Result { value: 'Hi there!', error: null, isOk: true }
> Result.ok()
Result { value: undefined, error: null, isOk: true }
> Result.error('Whoops!')
Result { value: null, error: 'Whoops!, isOk: false }

Additionally, there's a handful of static helper functions that are provided for convenience.

Result.unwrap() is used when you know the result does not contain an error. When you unwrap a result, it's value property will be returned. If there was a bug in your program, and the result actually contained an error, then a different error will be thrown stating that something went wrong, and will use the result's error value as the error's cause.

> Result.unwrap(Result.ok('Hi there!'))
'Hi there!'
> Result.unwrap(Result.ok())
undefined
> class MyError extends Error {}
> Result.unwrap(Result.error(new MyError('Whoops!')))
Error: Unwrapped a result object that was in an error state.
    ...
  [cause]: MyError: Whoops!

Result.assertOk() is just a nice helper function I like to have. Sometimes, I write side-effect functions that don't need to return any particular value (so they return a Result with undefined), but I still want to assert that the returned result doesn't contain an error. unwrap() just feels like it doesn't describe what I'm doing very well, so I like to have another function, assertOk(), that describes this objective.

> Result.unwrap(Result.ok('Hi there!'))
undefined // It always returned undefined
> Result.unwrap(Result.ok())
undefined
> class MyError extends Error {}
> Result.unwrap(Result.error(new MyError('Whoops!')))
Error: Unwrapped a result object that was in an error state.
    ...
  [cause]: MyError: Whoops!

Finally, Result.throwReturnedErrors() is used to transition between result-based logic and traditional error-handling logic. Say, for example, you want to take your existing API and convert it so it uses result types internally, but you still want to keep your public-facing API the same (or, you may wish to design new APIs to do this as well). You might design all of your internal functions to return result objects, then, at the point where you would export a function for the end-user to use, you'd first wrap it using Result.throwReturnedErrors() to cause all all errors from results to automatically be thrown, and all "ok" values to automatically be returned.

As a concrete example:

class DivisionByZero extends Error {}

function divide_(x, y) {
  if (y === 0) return Result.error(new DivisionByZero('You divided by zero!'))
  return x / y
}

// Other internal functions might call divide_() directly, and want to receive an instance
// of Result. This is why we wait until we export it to wrap this function, instead
// of doing it on the spot.

export const divide = Result.throwReturnedErrors(divide_)

// elsewhere

import { divide } from './wherever'

divide(3, 6) // 2
divide(3, 0) // Uncaught DivisionByZero [Error]: You divided by zero!

This is why Result.unwrap() throws an instance of Error() with the result's error value as a cause, instead of throwing the result's error value directly - you don't want someone further up to accidentally catch that error when it was never intended to be thrown at that specific point in time.

Anyways, there's a potential concrete API that could be added to JavaScript. Hopefully this gives us something tangible to discuss in this thread besides "yeah, it would be nice to have result types in the language".

2 Likes