From Either
to Raise
Typed errors in other functional ecosystems usually revolve around
dedicated types like Either
, which provide the ability to describe
computations that are successful or end in error. This style is
fully supported by Arrow, but the DSL based around Raise
usually
results in nicer code. This small guide describes common patterns
in the Either
style and how they translate into Raise
.
For a more general introduction to typed errors, without assuming any prior knowledge on the topic, check this tutorial.
In the following discussion, we assume the following functions:
fun f(n: Int): Either<Error, String>
fun g(s: String): Either<Error, Thing>
fun h(s: String): Either<Boo, Thing>
fun Thing.summarize(): String
Sequential composition
The basic way to compose computations that may error out is by
sequentially executing each of them, and finishing early if
an error appears. This is done using flatMap
— if the next
computation may also fail — or map
— if you need to apply
a pure computation to the result, if available.
Take this possible combination of the aforementioned functions:
fun foo(n: Int): Either<Error, String> =
f(n).flatMap { s ->
g(s).map { t ->
t.summarize()
}
}
The translation into the Raise
DSL describes the same sequence
of steps, but using a more sequential style:
fun foo(n: Int): Either<Error, String> = either {
val s = f(n).bind()
val t = g(s).bind()
t.summarize()
}
There are two important steps in this translation. At the beginning of
the function, we have the either
builder, which (1) indicates that
the result of the function is Either
of some error type and (2)
creates a new scope in which you can use the Raise
DSL.
The second step is that every time that you have an Either
value
resulting from another computation (like f
and g
above), you need
to call bind
. This short-circuits the current calculation in case
of error and continues with normal execution otherwise.
In this style you don't need to use any function to compose computations,
you use regular Kotlin idioms with some bind
s sprinkled. In fact,
you could have written the previous code in one single line:
fun foo(n: Int): Either<Error, String> = either {
g(f(n).bind()).bind().summarize()
}
How you split your code into local values no longer depends on the
structure of your functions, as is the case with flatMap
, but rather
on the logical decomposition you want in your code.
Arrow provides different builders for different return types
(either
, option
, result
), but regardless of the one you choose
you always use bind
at every step with potential failure.
At this point, you may be surprised that we haven't used the word Raise
at all in the code, only either
and bind
. To understand why we use
"Raise
DSL" to refer to this coding style, we need to dig a bit into
the type of the either
declaration itself.
fun <E, A> either(block: Raise<E>.() -> A): Either<E, A>
The fact that Raise<E>
appears at the front of the function type
of block
(formally, as an extension receiver)
means that all the functions from the Raise<E>
interface are available
implicitly in the scope of block
. The function bind
(alongside
raise
, ensure
, mapOrAccumulate
, and others) lives in that interface.
The pattern of having a parameter with a functional type with receiver
is not alien to Kotlin, either. Quite the contrary, it is
described in the documentation.
Other well-known libraries in the ecosystem, like kotlinx.coroutines
also follow this pattern (in coroutineScope
or flow
, for example).
Returning with logical error
Using wrapper types you often have a specific constructor that represents
the error case. For example, Left
signals failure when using Either
.
Using the Raise
DSL you no longer need to remember each of them,
failure is always signaled using raise
.
fun fooThatRaises(n: Int): Either<Error, String> = either {
if (n < 0) raise(Error.NegativeInput)
val s = f(n).bind()
val t = g(s).bind()
t.summarize()
}
Calling raise
immediately ends the execution of the current block. In the example
above, calling fooThatRaises(-1)
ends up in raise
, which in the case of an either
block
means resulting in Either.Left(Error.NegativeInput)
.
The propagation and early return of the Raise
scope look similar to exceptions.
That is deliberate, for familiarity. However, they serve a different purpose from exceptions.
Typed errors should be used for logical errors — problems that have
a place in your domain model — as opposed to exceptional cases — which represent
circumstances that are difficult to recover from.
For a more concrete example, Raise
is a good tool to signal problems like
"user not found in a database". On the other hand, "database connection suddenly dropped"
should rather use exceptions (maybe combined with resilience).
By the way, the Raise DSL provides several utility functions for common patterns. Checking an assertion and raising if false is one of those; so the most idiomatic way to write the code above is:
fun fooThatRaises(n: Int): Either<Error, String> = either {
ensure(n >= 0) { Error.NegativeInput }
val s = f(n).bind()
val t = g(s).bind()
t.summarize()
}
Transforming the error values
The fact that both f
and g
share the same Error
type is key for
the previous code blocks to compile. In every Raise
scope, we have one single
error type which you can raise
or bind
in the whole block of code.
If this is not the case, you have to bridge the different error types, which
is done using functions like mapLeft
.
fun bar(n: Int): Either<Error, String> =
f(n).flatMap { s ->
h(s).mapLeft {
it.toString()
}.map { t ->
t.summarize()
}
}
Within the Raise
DSL, the corresponding function is called withError
.
Following the discussion above, the withError
function creates a new
scope in which the error type is different. The first argument to
withError
describes how to transform failure values from the inner
scope into the error type of the outer scope.
fun bar(n: Int): Either<Error, String> = either {
val s = f(n).bind()
val t = withError({ boo -> boo.toError() }) { h(s).bind() }
t.summarize()
}
More than one failure
Sometimes you have not one, but many values for which you
want to run a potentially-failing computation. Almost every library
provides an "effectful map" (usually called traverse
) to cover those cases.
fun foos(xs: List<Int>) = xs.traverse { foo(it) }
Another advantage of the Raise
DSL is that you don't need all of those
new combinators. The regular map
on lists is enough — although you
need to remember to bind
each of the values.
fun foos(xs: List<Int>) = either {
xs.map { foo(it).bind() }
// alternatively
xs.map { foo(it) }.bindAll()
}
This advantage is bigger than just dropping a few function names. Since you don't need specific effectful combinators, you are free to use any function operating on collections of elements, without restriction.
By default Raise
operates on a fail-first basis: once a failure is raise
d,
the whole block comes to a halt. If you are operating on a collection, this
means that only the first error is reported. If you need to accumulate
all errors that arise in some block, you need to switch to mapOrAccumulate
.
More information can be found in the
Working with typed errors page.
Either
and bind
no more
Until this point, we are using the Raise
DSL to combine different Either
computations, with the goal of producing yet another Either
. The frontier
between both styles requires the use of bind
; but if you go full-on with
Raise
, we can even remove those. In that case, the error type appears
as the extension receiver, instead of wrapping the return type.
fun Raise<Error>.f(n: Int): String
fun Raise<Error>.g(s: String): Thing
fun Raise<Boo>.h(s: String): Thing
fun Thing.summarize(): String
As a consequence of return types being "bare", we can use them directly,
without the mediation of bind
. The last of our examples reads now:
fun Raise<Error>.bar(n: Int): String {
val s = f(n)
val t = withError({ boo -> boo.toError() }) { h(s) }
return t.summarize()
}
We encourage using this style, especially for non-public parts of your
code, instead of continuously using either
and bind
. Apart from the
stylistic improvement, it also avoids wrapping and unwrapping
Right
and Left
values.
Unfortunately, the current Kotlin language does not allow more than one receiver. That means that you cannot easily turn a function like:
fun Thing.problematic(): Either<Error, String>
into a similar version with Raise<Error>
as receiver. There is
an ongoing proposal, context parameters,
which shall drop this restriction. Until then, your best choice
is to move the original receiver into an argument.
fun Raise<Error>.problematic(thing: Thing): String