//arrow-core/arrow.core.continuations/Effect
Effect represents a function of suspend () -> A
, that short-circuit with a value of R (and Throwable), or completes with a value of A.
So Effect is defined by suspend fun <B> fold(f: suspend (R) -> B, g: suspend (A) -> B): B
, to map both values of R and A to a value of B
.
#writing-a-program-with-effect#handling-errors#structured-concurrency#arrow-fx-coroutines#parzip#partraverse#racen#bracketcase–resource#kotlinx#withcontext#async#launch#strange-edge-cases
Let’s write a small program to read a file from disk, and instead of having the program work exception based we want to turn it into a polymorphic type-safe program.
We’ll start by defining a small function that accepts a String, and does some simply validation to check that the path is not empty. If the path is empty, we want to program to result in EmptyPath
. So we’re immediately going to see how we can raise an error of any arbitrary type R by using the function shift
. The name shift
comes shifting (or changing, especially unexpectedly), away from the computation and finishing the Continuation
with R.
object EmptyPath
fun readFile(path: String): Effect<EmptyPath, Unit> = effect {
if (path.isEmpty()) shift(EmptyPath) else Unit
}
Here we see how we can define an Effect<R, A>
which has EmptyPath
for the shift type R, and Unit
for the success type A.
Patterns like validating a Boolean is very common, and the Effect DSL offers utility functions like kotlin.require and kotlin.requireNotNull. They’re named EffectScope.ensure and ensureNotNull to avoid conflicts with the kotlin
namespace. So let’s rewrite the function from above to use the DSL instead.
fun readFile2(path: String?): Effect<EmptyPath, Unit> = effect {
ensureNotNull(path) { EmptyPath }
ensure(path.isEmpty()) { EmptyPath }
}
Now that we have the path, we can read from the File
and return it as a domain model Content
. We also want to take a look at what exceptions reading from a file might occur FileNotFoundException
&SecurityError
, so lets make some domain errors for those too. Grouping them as a sealed interface is useful since that way we can resolve all errors in a type safe manner.
@JvmInline
value class Content(val body: List<String>)
sealed interface FileError
@JvmInline value class SecurityError(val msg: String?) : FileError
@JvmInline value class FileNotFound(val path: String) : FileError
object EmptyPath : FileError {
override fun toString() = "EmptyPath"
}
We can finish our function, but we need to refactor the return type from Unit
to Content
and the error type from EmptyPath
to FileError
.
fun readFile(path: String?): Effect<FileError, Content> = effect {
ensureNotNull(path) { EmptyPath }
ensure(path.isNotEmpty()) { EmptyPath }
try {
val lines = File(path).readLines()
Content(lines)
} catch (e: FileNotFoundException) {
shift(FileNotFound(path))
} catch (e: SecurityException) {
shift(SecurityError(e.message))
}
}
The readFile
function defines a suspend fun
that will return:
Content
of a given path
FileError
OutOfMemoryException
)Since these are the properties of our Effect
function, we can turn it into a value.
suspend fun main() {
readFile("").toEither() shouldBe Either.Left(EmptyPath)
readFile("knit.properties").toValidated() shouldBe Validated.Invalid(FileNotFound("knit.properties"))
readFile("gradle.properties").toIor() shouldBe Ior.Left(FileNotFound("gradle.properties"))
readFile("README.MD").toOption { None } shouldBe None
readFile("build.gradle.kts").fold({ _: FileError -> null }, { it })
.shouldBeInstanceOf<Content>()
.body.shouldNotBeEmpty()
}
The functions above are available out of the box, but it’s easy to define your own extension functions in terms of fold
. Implementing the toEither()
operator is as simple as:
suspend fun <R, A> Effect<R, A>.toEither(): Either<R, A> =
fold({ Either.Left(it) }) { Either.Right(it) }
suspend fun <A> Effect<None, A>.toOption(): Option<A> =
fold(::identity) { Some(it) }
Adding your own syntax to EffectScope<R>
is not advised, yet, but will be easy once “Multiple Receivers” become available.
context(EffectScope<R>)
suspend fun <R, A> Either<R, A>.bind(): A =
when (this) {
is Either.Left -> shift(value)
is Either.Right -> value
}
context(EffectScope<None>)
fun <A> Option<A>.bind(): A =
fold({ shift(it) }, ::identity)
Handling errors of type R is the same as handling errors for any other data type in Arrow. Effect<R, A>
offers handleError
, handleErrorWith
, redeem
, redeemWith
and attempt
.
As you can see in the examples below it is possible to resolve errors of R or Throwable
in Effect<R, A>
in a generic manner. There is no need to run Effect<R, A>
into Either<R, A>
before you can access R, you can simply call the same functions on Effect<R, A>
as you would on Either<R, A>
directly.
val failed: Effect<String, Int> =
effect { shift("failed") }
val resolved: Effect<Nothing, Int> =
failed.handleError { it.length }
val newError: Effect<List<Char>, Int> =
failed.handleErrorWith { str ->
effect { shift(str.reversed().toList()) }
}
val redeemed: Effect<Nothing, Int> =
failed.redeem({ str -> str.length }, ::identity)
val captured: Effect<String, Result<Int>> =
effect<String, Int> { 1 }.attempt()
suspend fun main() {
failed.toEither() shouldBe Either.Left("failed")
resolved.toEither() shouldBe Either.Right(6)
newError.toEither() shouldBe Either.Left(listOf('d', 'e', 'l', 'i', 'a', 'f'))
redeemed.toEither() shouldBe Either.Right(6)
captured.toEither() shouldBe Either.Right(Result.success(1))
}
Note: Handling errors can also be done with try/catch
but this is not recommended, it uses CancellationException
which is used to cancel Coroutine
s and is advised not to capture in Kotlin. The CancellationException
from Effect
is ShiftCancellationException
, this a public type, thus can be distinguished from any other CancellationException
if necessary.
Effect<R, A>
relies on kotlin.cancellation.CancellationException
to shift
error values of type R inside the Continuation
since it effectively cancels/short-circuits it. For this reason shift
adheres to the same rules as Structured Concurrency
Let’s overview below how shift
behaves with the different concurrency builders from Arrow Fx & KotlinX Coroutines. In the examples below we’re going to be using a utility to show how sibling tasks get cancelled. The utility function show below called awaitExitCase
will never
finish suspending, and completes a Deferred
with the ExitCase
. ExitCase
is a sealed class that can be a value of Failure(Throwable)
, Cancelled(CancellationException)
, or Completed
. Since awaitExitCase
suspends forever, it can only result in Cancelled(CancellationException)
.
suspend fun <A> awaitExitCase(exit: CompletableDeferred<ExitCase>): A =
guaranteeCase(::awaitCancellation) { exitCase -> exit.complete(exitCase) }
All operators in Arrow Fx Coroutines run in place, so they have no way of leaking shift
. It’s there always safe to compose effect
with any Arrow Fx combinator. Let’s see some small examples below.
suspend fun main() {
val error = "Error"
val exit = CompletableDeferred<ExitCase>()
effect<String, Int> {
parZip({ awaitExitCase<Int>(exit) }, { shift<Int>(error) }) { a, b -> a + b }
}.fold({ it shouldBe error }, { fail("Int can never be the result") })
exit.await().shouldBeTypeOf<ExitCase>()
}
suspend fun main() {
val error = "Error"
val exits = (0..3).map { CompletableDeferred<ExitCase>() }
effect<String, List<Unit>> {
(0..4).parTraverse { index ->
if (index == 4) shift(error)
else awaitExitCase(exits[index])
}
}.fold({ msg -> msg shouldBe error }, { fail("Int can never be the result") })
// It's possible not all parallel task got launched, and in those cases awaitCancellation never ran
exits.forEach { exit -> exit.getOrNull()?.shouldBeTypeOf<ExitCase.Cancelled>() }
}
parTraverse
will launch 5 tasks, for every element in 1..5
. The last task to get scheduled will shift
with “error”, and it will cancel the other launched tasks before returning.
suspend fun main() {
val error = "Error"
val exit = CompletableDeferred<ExitCase>()
effect<String, Int> {
raceN({ awaitExitCase<Int>(exit) }) { shift<Int>(error) }
.merge() // Flatten Either<Int, Int> result from race into Int
}.fold({ msg -> msg shouldBe error }, { fail("Int can never be the result") })
// It's possible not all parallel task got launched, and in those cases awaitCancellation never ran
exit.getOrNull()?.shouldBeTypeOf<ExitCase.Cancelled>()
}
raceN
races n
suspend functions in parallel, and cancels all participating functions when a winner is found. We can consider the function that shift
s the winner of the race, except with a shifted value instead of a successful one. So when a function in the race shift
s, and thus short-circuiting the race, it will cancel all the participating functions.
suspend fun main() {
val error = "Error"
val exit = CompletableDeferred<ExitCase>()
effect<String, Int> {
bracketCase(
acquire = { File("build.gradle.kts").bufferedReader() },
use = { reader: BufferedReader -> shift(error) },
release = { reader, exitCase ->
reader.close()
exit.complete(exitCase)
}
)
}.fold({ it shouldBe error }, { fail("Int can never be the result") })
exit.await().shouldBeTypeOf<ExitCase.Cancelled>()
}
suspend fun main() {
val error = "Error"
val exit = CompletableDeferred<ExitCase>()
fun bufferedReader(path: String): Resource<BufferedReader> =
Resource.fromAutoCloseable { File(path).bufferedReader() }
.releaseCase { _, exitCase -> exit.complete(exitCase) }
effect<String, Int> {
val lineCount = bufferedReader("build.gradle.kts")
.use { reader -> shift<Int>(error) }
lineCount
}.fold({ it shouldBe error }, { fail("Int can never be the result") })
exit.await().shouldBeTypeOf<ExitCase.Cancelled>()
}
It’s always safe to call shift
from withContext
since it runs in place, so it has no way of leaking shift
. When shift
is called from within withContext
it will cancel all Job
s running inside the CoroutineScope
of withContext
.
<!— INCLUDE import arrow.core.continuations.Effect import arrow.core.continuations.effect import arrow.core.continuations.ensureNotNull import arrow.fx.coroutines.ExitCase import arrow.fx.coroutines.guaranteeCase import io.kotest.assertions.fail import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileNotFoundException
Name | Summary |
---|---|
attempt | common open fun attempt(): Effect<R, Result<A» Runs the Effect and captures any NonFatal exception into Result. |
fold | common abstract suspend fun <B> fold(recover: suspend (R) -> B, transform: suspend (A) -> B): B Runs the suspending computation by creating a Continuation, and running the fold function over the computation.common open suspend fun <B> fold(error: suspend (error: Throwable) -> B, recover: suspend (R) -> B, transform: suspend (A) -> B): B Like fold but also allows folding over any unexpected Throwable that might have occurred. |
handleError | common open fun handleError(recover: suspend (R) -> A): Effect<Nothing, A> |
handleErrorWith | common open fun <R2> handleErrorWith(recover: suspend (R) -> Effect<R2, A>): Effect<R2, A> |
orNull | common open suspend fun orNull(): A? fold the Effect into an A?. Where the shifted value R is mapped to null, and result value A. |
redeem | common open fun <B> redeem(recover: suspend (R) -> B, transform: suspend (A) -> B): Effect<Nothing, B> |
redeemWith | common open fun <R2, B> redeemWith(recover: suspend (R) -> Effect<R2, B>, transform: suspend (A) -> Effect<R2, B>): Effect<R2, B> |
toEither | common open suspend fun toEither(): Either<R, A> fold the Effect into an Either. Where the shifted value R is mapped to Either.Left, and result value A is mapped to Either.Right. |
toIor | common open suspend fun toIor(): Ior<R, A> fold the Effect into an Ior. Where the shifted value R is mapped to Ior.Left, and result value A is mapped to Ior.Right. |
toOption | common open suspend fun toOption(orElse: suspend (R) -> Option<A>): Option<A> fold the Effect into an Option. Where the shifted value R is mapped to Option by the provided function orElse, and result value A is mapped to Some. |
toValidated | common open suspend fun toValidated(): Validated<R, A> fold the Effect into an Validated. Where the shifted value R is mapped to Validated.Invalid, and result value A is mapped to Validated.Valid. |
Name | Summary |
---|---|
toOption | common suspend fun <A> Effect<None, A>.toOption(): Option<A> |
toResult | common suspend fun <A> Effect<Throwable, A>.toResult(): Result<A> |
Do you like Arrow?
✖