Programming with monads such as Result
, Either
or Try
aka Railway Oriented Programming is an elegant way to write
programs that clearly separate domain logic from error handling. With for-comprehension-ts we aim to overcome
three major limitations that we encountered in the application of this approach to real-world programs with TypeScript:
Typical applications such as validation of complex data objects. Such scenarios may have 10 or more steps and the
output of the first steps is required in multiple further steps. Implementing this with method chaining
am.flatMap((a) => bm.flatMap((b) => ...)
quickly leads to large closures and according accordingly large pyramids of
braces. We also believe that pipes pipe(result, (a) => success([a, a + 1]), ([a, b]) => ...)
are no elegant solution to this
problem as parameters must be explicitly passed along functions.
In other programming languages there are language features such as Haskell's do-notation or Scala's for-comprehension that tackle this problem. For example, Scala's for-comprehension looks like this:
val result: Either[String, Int] =
for {
dividend <- Right(42)
divisor <- Right(2)
divisorVerified <- if (divisor > 0) Right(divisor)
else Left("Divisor must be > 0!")
} yield dividend / divisorVerified
println(result match {
case Right(value) => value
case Left(error) => error
})
With for-comprehension-ts we provide a similar syntax for TypeScript:
const result = For
.result<string>()
.sync("dividend", () => success(42))
.fm("divisor", () => success(2))
.fm("divisorVerified", ({divisor}) =>
divisor > 0
? success(divisor)
: failure<number, string>("Divisor must be > 0!"))
.yield(({dividend, divisorVerified}) => dividend / divisorVerified)
console.log(isSuccess(result) ? result.value : result.error)
We believe that for-comprehension-ts improves readability of railway oriented code. We start with For.result<E>()
to
set a custom error type E
. With each of the following lines (constructor sync
or flatMap fm
) we add a value to
a parameter object that is available to all following steps. The field name is set by passing a string as first
parameter. By destructuring its easy to see where these values are read later on.
Finally, by passing a map function to the yield
operator, a single value is derived from all intermediate ones.
Of course, the programm will only be executed until the first step results into failure.
Besides this, our programm declaration is fully type-safe and requires only few type hints.
In the first version of this library, we tried to make for-comprehension generic to support custom monads.
However, due to the missing support for higher kinded types we failed to do this in a way that is both
easy to read and without the need for excessive type hints. As a result, we focussed on a Result<T, E>
monad as, in our opinion, it is the most important
one in real-word applications.
Another major problem we were facing while working with Monads in TypeScript was the permanent dealing with
Promise<Result<T, E>>
. With for-comprehension-ts, we enable a seamless integration of regular and async functions
using the same syntax:
const result = await For
.result<string>()
.async("dividend", async () => success(42))
.fm("divisor", () => success(2))
.fm("divisorVerified", ({divisor}) =>
divisor > 0
? success(divisor)
: failure<number, string>("Divisor must be > 0!"))
.yield(({dividend, divisorVerified}) => dividend / divisorVerified)
console.log(isSuccess(result) ? result.value : result.error)
The only differences are:
- alternative constructor
async<E>
- support for async functions for both constructor
async
and flatMapfm
yield<T>
will result into aPromise<Result<T, E>>
.
There are scenarios where the simultaneous execution of multiple promises will lead to faster execution of programs.
For example, if waiting times for external services do not add up. To allow for such optimizations, we provide fmp
, a
variant of flatMap where required input fields of the parameter object must be whitelisted. Such programs form a
directed acyclic graph (DAG) whose vertices are named values (e.g., a = 3) and where edges symbolize asynchronous flatMap
operations. Based on this information, we can optimize promise execution. Although it is possible to declare such
programs, the actual optimization is not implemented yet. We hope to add this feature soon.
const result = await For
.result<string>()
.async("a", () => serviceA())
.fmp("b", ["a"], ({a}) => serviceB(a))
.fmp("c", ["a"], ({a}) => serviceC(a))
.yield(({b, c}) => b + c)
If you like for-comprehension-ts, please feel free to contribute in any way!