eitherway
Yet Another Option and Result Implementation (YAORI)
Safe abstractions for fallible flows inspired by F# and Rust.
Disclaimer
🚧 This project is still under development, expect some breaking changes 🚧
Please see this tracking issue for more information regarding when to expect a stable release.
Contents
- Motivation
- Design Goals
eitherway
in Action- API
- Best Practices
- FAQ
- Prior Art
- License and Contributing
Motivation
Let's be honest: The community already nurtured a bunch of great projects, providing similar abstractions. The goal has always been to make it easier and more explicit to handle error cases under the "errors are values" premise. After having worked with quite a few of these existing abstractions, a couple of issues arose/persisted:
- Overly strict: Some of the existing implementations don't account for the variance emerging naturally in a structural type system.
- Onboarding is hard: Most of the existing projects provide either very little or scattered documentation. Forcing new users to constantly switch context in order to understand how they can achieve their goal or if their chosen operation is suitable for their use case.
- Lack of async support: Very few existing projects offer abstractions for
working in an
async
context, none of them really being first class citizens.
The goal here really is to make the abstractions provided by eitherway
the
most safe, productive and overall enjoyable to work with. Irrespective of
experience or employed context.
Design Goals
eitherway
is trying to close the gap between type-safety, idiom and
productivity by focusing on the following goals:
- Pragmatism: There is no need to strictly port something from another
language with very different semantics. When decisions arrive, which call for
a trade-off,
eitherway
will always try to offer a solution geared towards the constraints and idiom of Typescript. - Compatibility: Interacting with one of the structures defined by
eitherway
should be painless in the sense that things do what you would expect them to do in Typescript and are compatible with inherent language protocols (e.g. Iterator protocol). - Safety: Once an abstraction is instantiated, no inherent operation should ever panic.
- Performance: All abstractions provided here should strive to amortize the cost of usage and be upfront about these costs.
- Documentation: All structures come with full, inline documentation. Making it easy to understand what is currently happening and if the chosen operation is suitable for the desired use case. (Still very much in progress)
eitherway
in Action
import { Option, Result, Task } from "https://deno.land/x/eitherway/mod.ts";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* A little API over-use to show what's possible.
* This is not the most efficient way to write code, but still performs well
* enough in benchmarks.
*/
function toUpperCase(input: string | undefined): Task<string, TypeError> {
return Option(input) // All nullish values are None
.okOrElse(() => TypeError("Input is undefined")) // Convert to Result<string, TypeError>
.into((res) => Task.of(res)) // Push the result into an async context
.map(async (str) => {
await sleep(1);
return str.toUpperCase();
});
}
function stringToLength(input: string): Task<number, TypeError> {
return Option.fromCoercible(input) // All falsy types are None
.okOrElse(() => TypeError("Input string is empty"))
.into((res) => Task.of(res))
.map(async (str) => {
await sleep(1);
return str.length;
});
}
function powerOfSelf(input: number): Task<number, TypeError> {
return Option.fromCoercible(input)
.okOrElse(() =>
TypeError("Cannot perform computation with NaN, Infinity or 0")
)
.into((res) => Task.of(res))
.andThen(async (n) => { // Promise<Result<T, E>> and Task<T, E> can be used interchangeably for async composition
await sleep(1);
return Option.fromCoercible(Math.pow(n, n))
.okOrElse(() => TypeError("Cannot calculate result"));
});
}
function processString(input: string | undefined): Task<number, TypeError> {
return toUpperCase(input) // Synchronous and asynchronous composition work the same
.andThen(stringToLength)
.andThen(powerOfSelf);
}
async function main(): Promise<Result<number, TypeError>> {
const result = await processString("foo"); // Task is of course awaitable
return Task.of(result); // You can return Task<T, E> as Promise<Result<T, E>>
}
main()
.then((result) => {
result // Result<number, TypeError>
.inspect(console.log)
.inspectErr(console.error);
})
.catch((e) => "Unreachable!")
.finally(() => console.log("DONE!"));
Installation
Minimum Required Runtime Versions
eitherway
internally uses the
Error.cause
configuration option and
structuredClone
,
therefore please make sure these versions are met:
deno
: >=1.14.node
: >=17.0.0Browser
:Error.cause
&structuredClone
deno |
node |
Edge |
Chrome |
Firefox |
Safari |
---|---|---|---|---|---|
>=1.14.0 |
>=17.0.0 |
>=119 |
>=119 |
>=119 |
>=17.1 |
deno
import {
Err,
None,
Ok,
Option,
Result,
Some,
Task,
} from "https://deno.land/x/eitherway/mod.ts";
node
(npm | pnpm | yarn) add eitherway
esm
:
import { Err, None, Ok, Option, Result, Some, Task } from "npm:eitherway";
cjs
:
const { Err, Ok, Option, None, Result, Some, Task } = require("eitherway");
API
Overview
On a high level, eitherway
provides 3 basic abstractions, which have different
use cases:
Option<T>
: Composable equivalent of the unionT | undefined
. Use this to handle the case of non-existence gracefully or assert a certain fact about a value.Result<T, E>
: Composable equivalent of the unionT | E
. Use this to gracefully handle an happy-path and error-path without the need to throw exceptions.Task<T, E>
: Composable equivalent ofPromise<T | E>
. Same asResult
but for asynchronous operations.
Design Decisions
If you are coming from other languages, or other libraries, you will be familiar with most parts already. A couple of things are handled differently though:
- Thinking in unions: Union types are ubiquitous and a powerful feature of
Typescript. The abstractions provided by
eitherway
were modeled to provide a tag to members of unions commonly used. As a consequence, there are nosafe
orunchecked
variants for methods like.unwrap()
.
import { Ok, Option, Result } from "https://deno.land/x/eitherway/mod.ts";
const opt: Option<string> = Option("foo");
const res: Result<number, TypeError> = Ok(1);
// Without narrowing, the union type is returned
const maybeString: string | undefined = opt.unwrap();
const numOrError: number | TypeError = res.unwrap();
// The type can easily be narrowed though
if (res.isErr()) {
console.error(res.unwrap());
}
const num: number = res.unwrap();
- Upholding basic invariants: You CANNOT construct an instance of
Option<undefined | null>
and you MUST NOT throw exceptions when returningResult<T, E>
orTask<T, E>
from a function. - Don't panic: Following the previous statements,
eitherway
does not throw or re-throw exceptions under normal operations. In fact, there are only 3 scenarios, which lead to a panic at runtime:- Trying to shove a nullish value into
Some
. The compiler will not allow this, but if you perform a couple of type casts, or a library you depend on provides wrong type declarations, theSome
constructor will throw an exception, when you end up trying to instantiate it with a nullish value. - Trying to lift a
Promise
or a function, which you've explicitly provided as infallible, into aResult
orTask
context and it ends up panicking. - You, despite being told multiple times not to do so, chose to panic in a
function you've implicitly marked as infallible by returning a
Result<T, E>
, aPromise<Result<T, E>>
or aTask<T, E>
.
- Trying to shove a nullish value into
- Closure of operations: All mapping and chaining operations are closed, meaning that they return an instance of the same abstraction as the one they were called on.
Additions
Some notable additions, which you may have been missing in other libraries:
- Composable side-effects:
.tap()
,.inspect()
and.inspectErr()
methods. - Pass-through conditionals:
.andEnsure()
&.orEnsure()
methods. - Sync & Async feature parity:
Result<T, E>
andTask<T, E>
provide the same API for composing operations. Only the predicates.isOk()
and.isErr()
are not implemented onTask<T, E>
(for obvious reasons). - Composability helpers: Higher order
.lift()
and.liftFallible()
functions. Eliminating the need to manually wrap library or existing code in many situations. - Collection helpers: Exposed via the namespaces
Options
,Results
andTasks
, every abstraction provides functions to collect tuples, arrays and iterables into the base abstraction.
Option
- Overview and factories
- Base interface implemented
by
Some<T>
andNone
- Collection helpers
Result
- Overview and factories
- Base interface implemented
by
Ok<T>
andErr<E>
- Collection helpers
Task
Best Practices
- Computations - not data: The abstractions provided by
eitherway
are meant to represent the results of computations, not data. - Embrace immutability: Don't mutate your state. That's it.
- Return early occasionally: When building up longer pipelines of operations, especially if they involve synchronous and asynchronous operations, you may want to break out of a pipeline to not enqueue micro-tasks needlessly. The need to do this, does arise less frequently than one might think though.
- Unwrap at the edges: Most application frameworks and library consumers
expect that any errors are propagated through exceptions (and hopefully
documented). Therefore, it's advised to unwrap
Option
s,Result
s andTask
s at the outer most layer of your code. In a simple CRUD application, this might be an error handling interceptor, a controller, or the implementation of your public API in case of a library. - Some errors are unrecoverable: In certain situations the "errors are values" premise falls short on practicality. burntsushi, Rob Pike and many others have already written extensively about this. When encountering a truly unrecoverable error or an impossible state, throwing a runtime exception is a perfectly valid solution.
- Discriminate but don't over-accumulate: It's often very tempting, to just
accumulate possible errors as a discriminated union when building out flows
via composition of
Result
andTask
pipelines and let the user or the next component in line figure out what to do next. This only works up to a certain point. Errors are important domain objects, and they should be modeled accordingly. - Lift others up to help yourself out: Use the composability helpers. They really reduce noise and speed up integrating external code a lot.
import { Option, Result } from "https://deno.land/x/eitherway/mod.ts";
import * as semver from "https://deno.land/std@0.206.0/semver/mod.ts";
const noInputProvidedError = Error("No input provided");
const toParseError = (e: unknown) =>
TypeError("Could not parse version", { cause: e });
const tryParse = Result.liftFallible(
semver.parse,
toParseError,
);
const version = Option.from(Deno.args[0])
.okOr(noInputProvidedError)
.andThen(tryParse);
FAQ
Q: Why should I even use something like this?
A: It's nice. Really.Explicit error types and built-in happy/error path selectors lead to expressive code which is often even more pleasant to read.
Compare these examples, taken from the benchmark suite:
Synchronous:
/* Classic exception style */
declare function toUpperCase(input: string | undefined): string;
declare function stringToLength(input: string): number;
declare function powerOfSelf(input: number): number;
function processString(input: string | undefined): number {
try {
const upperCased = toUpperCase(input);
const length = stringToLength(upperCased);
return powerOfSelf(length);
} catch (error: unknown) {
if (error instanceof TypeError) {
console.error(error.message);
throw error;
}
throw new TypeError("Unknown error", { cause: error });
}
}
/* Equivalent Result flow */
import { Result } from "https://deno.land/x/eitherway/mod.ts";
declare function toUpperCase(
input: string | undefined,
): Result<string, TypeError>;
declare function stringToLength(input: string): Result<number, TypeError>;
declare function powerOfSelf(input: number): Result<number, TypeError>;
function processString(input: string | undefined): Result<number, TypeError> {
return toUpperCase(input)
.andThen(stringToLength)
.andThen(powerOfSelf)
.inspectErr((e) => console.error(e.message));
}
Asynchronous:
/* Classic exception style */
declare function toUpperCase(input: string | undefined): Promise<string>;
declare function stringToLength(input: string): Promise<number>;
declare function powerOfSelf(input: number): Promise<number>;
async function processString(input: string | undefined): Promise<number> {
try {
const upperCased = await toUpperCase(input);
const length = await stringToLength(upperCased);
return await powerOfSelf(length);
} catch (error: unknown) {
if (error instanceof TypeError) {
console.error(error.message);
throw error;
}
throw new TypeError("Unknown error", { cause: error });
}
}
/* Equivalent Task flow */
import { Result, Task } from "https://deno.land/x/eitherway/mod.ts";
declare function toUpperCase(
input: string | undefined,
): Task<string, TypeError>;
declare function stringToLength(
input: string,
): Promise<Result<number, TypeError>>;
declare function powerOfSelf(input: number): Task<number, TypeError>;
function processString(input: string | undefined): Task<number, TypeError> {
return toUpperCase(input)
.andThen(stringToLength)
.andThen(powerOfSelf)
.inspectErr((e) => console.error(e.message));
}
Apart from making error cases explicit, the abstractions provided here foster a code style, which naturally builds up complex computations via composition of small, focused functions/methods, where boundaries are defined by values. Thus leading to a highly maintainable and easily testable code base.
Even better: These abstractions come with practically no overhead (see the next section).
Here are a couple of videos, explaining the general benefits in more detail:
Q: What is the performance impact of using this?
A: Practically none.You can run the benchmark suite yourself with $ deno bench
.
The benchmark results suggest, that for nearly all practical considerations
there is no or virtually no overhead of using the abstractions provided by
eitherway
vs. a classic exception propagation style.
Although the result and task flows were slightly faster in the runs below, it's important not to fall into a micro-optimization trap. The conclusion should not necessarily be "use eitherway, it's faster", but rather "use eitherway, it's practically free".
The overall performance thesis is that by returning errors instead of throwing, catching and re-throwing exceptions, the instantiation costs of the abstractions provided here are amortized over call-stack depth & it's size, as well as the optimizations the linear return path allows, sometimes even leading to small performance improvements. This sounds plausible, and the results are not refuting the null hypothesis here, but benchmarking is hard and for most use cases, the difference really won't matter.
Synchronous exception propagation vs. result chaining
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz runtime: deno 1.33.2
(x86_64-apple-darwin)
## file:///projects/eitherway/bench/sync_bench.ts benchmark time (avg) (min … max) p75 p99 p995
SyncExceptions 29.15 µs/iter (20.54 µs … 472 µs) 31.34 µs 38.22 µs 49.28 µs
SyncResultFlow 15.49 µs/iter (11.07 µs … 441.17 µs) 15.44 µs 31.69 µs 43.37 µs
summary SyncResultFlow 1.88x faster than SyncExceptions
Asynchronous exception propagation vs. task chaining
cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz runtime: deno 1.33.2
(x86_64-apple-darwin)
## file:///projects/eitherway/bench/async_bench.ts benchmark time (avg) (min … max) p75 p99 p995
AsyncExceptions 24.78 ms/iter (22.08 ms … 25.55 ms) 25.46 ms 25.55ms 25.55ms
TaskInstanceFlow 23.88 ms/iter (21.28 ms … 25.8 ms) 24.57 ms 25.8ms 25.8ms
TaskOperatorFlow 24.21 ms/iter (21.33 ms … 25.73 ms) 25.36 ms 25.73ms 25.73ms
TaskEarlyReturnFlow 24.04 ms/iter (20.36 ms … 25.47 ms) 25.42 ms 25.47ms 25.47ms
summary TaskInstanceFlow 1.01x faster than TaskEarlyReturnFlow 1.01x faster than
TaskOperatorFlow 1.04x faster than AsyncExceptions
Micro benchmarks
If you have a highly performance sensitive use case, you should be using a different language. On a more serious note, also small costs can add up and as a user, you should know how high the costs are. So here are a few micro benchmarks:cpu: Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz runtime: deno 1.33.2
(x86_64-apple-darwin)
## file:///projects/eitherway/bench/micro_bench.ts benchmark time (avg) (min … max) p75 p99 p995
Promise.resolve(Ok) 44.33 ns/iter (35.81 ns … 106.41 ns) 44.6 ns 62.58 ns
72.56ns Task.succeed 105.43 ns/iter (88.44 ns … 227.26 ns) 108.97 ns 204.75 ns
212.54ns Promise.resolve(Err) 3.11 µs/iter (3.06 µs … 3.27 µs) 3.13 µs 3.27 µs
3.27 µs Task.fail 2.94 µs/iter (2.71 µs … 3.35 µs) 3.25 µs 3.35 µs 3.35 µs
summary Promise.resolve(Ok) 2.38x faster than Task.succeed 66.41x faster than
Task.fail 70.14x faster than Promise.resolve(Err)
## file:///projects/eitherway/bench/micro_bench.ts benchmark time (avg) (min … max) p75 p99 p995
Ok 5.1 ns/iter (4.91 ns … 22.27 ns) 5.02 ns 8.62 ns 11.67 ns Err 4.88 ns/iter
(4.7 ns … 17.93 ns) 4.81 ns 8.18 ns 10.52 ns Option 90.39 ns/iter (83.63 ns …
172.61 ns) 93.31 ns 135.19 ns 146.79 ns
summary Err 1.05x faster than Ok 18.52x faster than Option
## file:///projects/eitherway/bench/micro_bench.ts benchmark time (avg) (min … max) p75 p99 p995
Async Exception Propagation 9.08 µs/iter (8.95 µs … 9.26 µs) 9.18 µs 9.26 µs
9.26 µs Async Error Propagation 6.32 µs/iter (6.24 µs … 6.52 µs) 6.37 µs 6.52 µs
6.52 µs
summary Async Error Propagation 1.44x faster than Async Exception Propagation
Q: Why can't I use Task as the return type of an async function?
A: That's a general restriction of JavaScript.A function defined with the async
keyword, must return a "system" Promise
.
Although Task<T, E>
(currently) is a proper subclass of Promise
, it cannot
be used in the Return Type Position of an async function, because it's NOT a
"system" promise (for lack of a better word).
Since Task<T, E>
is a subclass of Promise<Result<T, E>>
, it's possible to
return it as such from an async function though or just await it.
import { Result, Task } from "https://deno.land/x/eitherway/mod.ts";
async function toTask(str: string): Promise<Result<string, never>> {
return Task.succeed(str);
}
Furthermore, Task<T, E>
is merely a composability extension for
Promise<Result<T, E>>
. As such, you can cheaply convert every
Promise<Result<T, E>
via the Task.of()
constructor, or use the promise
operators to compose your pipeline.
Q: Why subclassing Promises instead of just providing a PromiseLike abstraction?
A: For compatibility reasons.The drawback of the current implementation is that we cannot evaluate
Task<T, E>
lazily. On the other hand, a lot of framework or library code is
still (probably needlessly) invariant over PromiseLike
types. Therefore
subclassing the native Promise
and allowing the users to treat
Promise<Result<T, E>>
and Task<T, E>
interchangeably in most situations, was
the preferred solution.
Prior Art
License and Contributing
Contributing
Please see CONTRIBUTING for more information.
License
Licensed under MIT license.