
commonMain.arrow.core.raise.Effect.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of arrow-core Show documentation
Show all versions of arrow-core Show documentation
Functional companion to Kotlin's Standard Library
@file:JvmMultifileClass
@file:JvmName("RaiseKt")
@file:OptIn(ExperimentalTypeInference::class)
package arrow.core.raise
import kotlin.experimental.ExperimentalTypeInference
import kotlin.jvm.JvmMultifileClass
import kotlin.jvm.JvmName
/**
* [Effect] represents a function of `suspend Raise.() -> A`, that short-circuit with a value of `R` or `Throwable`, or completes with a value of `A`.
*
* So [Effect] is defined by `suspend fun fold(recover: suspend (Throwable) -> B, resolve: suspend (R) -> B, transform: suspend (A) -> B): B`,
* to map all values of `R`, `Throwable` and `A` to a value of `B`.
*
*
* [Writing a program with Effect](#writing-a-program-with-effect)
* [Handling errors](#handling-errors)
* [recover](#recover)
* [catch](#catch)
* [Structured Concurrency](#structured-concurrency)
* [Arrow Fx Coroutines](#arrow-fx-coroutines)
* [parZip](#parzip)
* [parTraverse](#partraverse)
* [raceN](#racen)
* [bracketCase / Resource](#bracketcase--resource)
* [KotlinX](#kotlinx)
* [withContext](#withcontext)
* [async](#async)
* [launch](#launch)
* [Strange edge cases](#strange-edge-cases)
*
*
* ## Writing a program with Effect
*
* 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 `raise`. The name `raise` comes raising an intterupt, or
* changing, especially unexpectedly, away from the computation and finishing the `Continuation` with `R`.
*
*
* ```kotlin
* object EmptyPath
*
* fun readFile(path: String): Effect = effect {
* if (path.isEmpty()) raise(EmptyPath) else Unit
* }
* ```
*
* Here we see how we can define an `Effect` which has `EmptyPath` for the raise 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 [ensure] and [ensureNotNull] to avoid conflicts with the `kotlin` namespace.
* So let's rewrite the function from above to use the DSL instead.
*
* ```kotlin
* fun readFile2(path: String?): Effect = effect {
* ensureNotNull(path) { EmptyPath }
* ensure(path.isNotEmpty()) { 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.
*
*
* ```kotlin
* @JvmInline
* value class Content(val body: List)
*
* 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`.
*
* ```kotlin
* fun readFile(path: String?): Effect = effect {
* ensureNotNull(path) { EmptyPath }
* ensure(path.isNotEmpty()) { EmptyPath }
* try {
* val lines = File(path).readLines()
* Content(lines)
* } catch (e: FileNotFoundException) {
* raise(FileNotFound(path))
* } catch (e: SecurityException) {
* raise(SecurityError(e.message))
* }
* }
* ```
*
* The `readFile` function defines a `suspend fun` that will return:
*
* - the `Content` of a given `path`
* - a `FileError`
* - An unexpected fatal error (`OutOfMemoryException`)
*
* Since these are the properties of our `Effect` function, we can turn it into a value.
*
* ```kotlin
* suspend fun main() {
* readFile("").toEither() shouldBe Either.Left(EmptyPath)
* 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()
* .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:
*
*
* ```kotlin
* suspend fun Effect.toEither(): Either =
* fold({ Either.Left(it) }) { Either.Right(it) }
*
* suspend fun Effect.toOption(): Option =
* fold(::identity) { Some(it) }
* ```
*
*
* Adding your own syntax to `Raise` is not advised, yet, but will be easy once context parameters become available.
*
* ```
* context(_: Raise)
* suspend fun Either.bind(): A =
* when (this) {
* is Either.Left -> raise(value)
* is Either.Right -> value
* }
*
* context(_: Raise)
* fun Option.bind(): A =
* fold({ raise(it) }, ::identity)
* ```
*
* ## Handling errors
*
* An Effect has 2 error channels: `Throwable` and `R`
* There are two separate handlers to transform either of the error channels.
*
* - `recover` to handle, and transform any error of type `R`.
* - `catch` to handle, and transform and error of type `Throwable`.
*
* ### recover
*
* `recover` handles the error of type `R`,
* by providing a new value of type `A`, raising a different error of type `E`, or throwing an exception.
*
* Let's take a look at some examples:
*
* We define a `val failed` of type `Effect`, that represents a failed effect with value "failed".
*
*
* ```kotlin
* val failed: Effect =
* effect { raise("failed") }
* ```
*
* We can `recover` the failure, and resolve it by providing a default value of `-1` or the length of the `error: String`.
*
* ```kotlin
* val default: Effect =
* failed.recover { -1 }
*
* val resolved: Effect =
* failed.recover { it.length }
* ```
*
* As you can see the resulting `error` is now of type `Nothing`, since we did not raise any new errors.
* So our `Effect` knows that no short-circuiting will occur during execution. Awesome!
* But it can also infer to any other error type that you might want instead, because it's never going to occur.
* So as you see below, we can even assign our `Effect` to `Effect`, where `E` can be any type.
*
* ```kotlin
* val default2: Effect = default
* val resolved2: Effect = resolved
* ```
*
* `recover` also allows us to _change_ the error type when we resolve the error of type `R`.
* Below we handle our error of `String` and turn it into `List` using `reversed().toList()`.
* This is a powerful operation, since it allows us to transform our error types across boundaries or layers.
*
* ```kotlin
* val newError: Effect, Int> =
* failed.recover { str ->
* raise(str.reversed().toList())
* }
* ```
*
* Finally, since `recover` supports `suspend` we can safely call other `suspend` code and throw `Throwable` into the `suspend` system.
* This is typically undesired, since you should prefer lifting `Throwable` into typed values of `R` to make them compile-time tracked.
*
* ```kotlin
* val newException: Effect =
* failed.recover { str -> throw RuntimeException(str) }
* ```
*
* ### catch
*
* `catch` gives us the same powers as `recover`, but instead of resolving `R` we're recovering from any unexpected `Throwable`.
* Unexpected, because the expectation is that all `Throwable` get turned into `R` unless it's a fatal/unexpected.
* This operator is useful when you need to work/wrap foreign code, especially Java SDKs or any code that is heavily based on exceptions.
*
* Below we've defined a `foreign` value that represents wrapping a foreign API which might throw `RuntimeException`.
*
* ```kotlin
* val foreign = effect {
* throw RuntimeException("BOOM!")
* }
* ```
*
* We can `catch` to run the effect recovering from any exception,
* and recover it by providing a default value of `-1` or the length of the [Throwable.message].
*
* ```kotlin
* val default3: Effect =
* foreign.catch { -1 }
*
* val resolved3: Effect =
* foreign.catch { it.message?.length ?: -1 }
* ```
*
* A big difference with `recover` is that `catch` **cannot** change the error type of `R` because it doesn't resolve it, so it stays unchanged.
* You can however compose `recover`, and `v` to resolve the error type **and** recover the exception.
*
* ```kotlin
* val default4: Effect =
* foreign
* .recover { -1 }
* .catch { -2 }
* ```
*
* `catch` however offers an overload that can _refine the exception_.
* Let's say you're wrapping some database interactions that might throw `java.sql.SqlException`, or `org.postgresql.util.PSQLException`,
* then you might only be interested in those exceptions and not `Throwable`. `catch` allows you to install multiple handlers for specific exceptions.
* If the desired exception is not matched, then it stays in the `suspend` exception channel and will be thrown or recovered at a later point.
*
* ```kotlin
* val default5: Effect =
* foreign
* .catch { _: RuntimeException -> -1 }
* .catch { _: java.sql.SQLException -> -2 }
* ```
*
* Finally, since `catch` also supports `suspend` we can safely call other `suspend` code and throw `Throwable` into the `suspend` system.
* This can be useful if refinement of exceptions is not sufficient, for example in the case of `org.postgresql.util.PSQLException` you might want to
* check the `SQLState` to check for a `foreign key violation` and rethrow the exception if not matched.
*
* ```kotlin
* suspend fun java.sql.SQLException.isForeignKeyViolation(): Boolean = true
*
* val rethrown: Effect =
* failed.catch { ex: java.sql.SQLException ->
* if(ex.isForeignKeyViolation()) raise("foreign key violation")
* else throw ex
* }
* ```
*
*
*
* 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 `RaiseCancellationException`, this a public type, thus can be distinguished from any other `CancellationException` if necessary.
*
* ## Structured Concurrency
*
* `Effect` relies on `kotlin.cancellation.CancellationException` to `raise` error values of type `R` inside the `Continuation` since it effectively cancels/short-circuits it.
* For this reason `raise` adheres to the same rules as [`Structured Concurrency`](https://kotlinlang.org/docs/coroutines-basics.html#structured-concurrency)
*
* Let's overview below how `raise` 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)`.
*
*
* ```kotlin
* suspend fun awaitExitCase(exit: CompletableDeferred): A =
* guaranteeCase(::awaitCancellation) { exitCase -> exit.complete(exitCase) }
*
* ```
*
* ### Arrow Fx Coroutines
* All operators in Arrow Fx Coroutines run in place, so they have no way of leaking `raise`.
* It's there always safe to compose `effect` with any Arrow Fx combinator. Let's see some small examples below.
*
* #### parZip
*
* ```kotlin
* suspend fun main() {
* val error = "Error"
* val exit = CompletableDeferred()
* effect {
* parZip({ awaitExitCase(exit) }, { raise(error) }) { a: Int, b: Int -> a + b }
* }.fold({ it shouldBe error }, { fail("Int can never be the result") })
* exit.await().shouldBeTypeOf()
* }
* ```
*
*
* #### parTraverse
*
* ```kotlin
* suspend fun main() {
* val error = "Error"
* val exits = (0..3).map { CompletableDeferred() }
* effect> {
* (0..4).parMap { index ->
* if (index == 4) raise(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() }
* }
* ```
*
*
* `parTraverse` will launch 5 tasks, for every element in `1..5`.
* The last task to get scheduled will `raise` with "error", and it will cancel the other launched tasks before returning.
*
* #### raceN
*
* ```kotlin
* suspend fun main() {
* val error = "Error"
* val exit = CompletableDeferred()
* effect {
* raceN({ awaitExitCase(exit) }) { raise(error) }
* .merge() // Flatten Either 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()
* }
* ```
*
*
* `raceN` races `n` suspend functions in parallel, and cancels all participating functions when a winner is found.
* We can consider the function that `raise`s the winner of the race, except with a raised value instead of a successful one.
* So when a function in the race `raise`s, and thus short-circuiting the race, it will cancel all the participating functions.
*
* #### bracketCase / Resource
*
* ```kotlin
* suspend fun main() {
* val error = "Error"
* val exit = CompletableDeferred()
* effect {
* bracketCase(
* acquire = { File("build.gradle.kts").bufferedReader() },
* use = { _: BufferedReader -> raise(error) },
* release = { reader, exitCase ->
* reader.close()
* exit.complete(exitCase)
* }
* )
* }.fold({ it shouldBe error }, { fail("Int can never be the result") })
* exit.await().shouldBeTypeOf()
* }
* ```
*
*
*
* ```kotlin
* suspend fun main() {
* val error = "Error"
* val exit = CompletableDeferred()
*
* suspend fun ResourceScope.bufferedReader(path: String): BufferedReader =
* autoCloseable { File(path).bufferedReader() }.also {
* onRelease { exitCase -> exit.complete(exitCase) }
* }
*
* resourceScope {
* effect {
* val reader = bufferedReader("build.gradle.kts")
* raise(error)
* reader.lineSequence().count()
* }.fold({ it shouldBe error }, { fail("Int can never be the result") })
* }
* exit.await().shouldBeTypeOf()
* }
* ```
*
*
* ### KotlinX
* #### withContext
* It's always safe to call `raise` from `withContext` since it runs in place, so it has no way of leaking `raise`.
* When `raise` is called from within `withContext` it will cancel all `Job`s running inside the `CoroutineScope` of `withContext`.
*
*
* ```kotlin
* suspend fun main() {
* val exit = CompletableDeferred()
* effect {
* withContext(Dispatchers.IO) {
* val job = launch { awaitExitCase(exit) }
* val content = readFile("failure").bind()
* job.join()
* content.body.size
* }
* }.fold({ e -> e shouldBe FileNotFound("failure") }, { fail("Int can never be the result") })
* exit.await().shouldBeInstanceOf()
* }
* ```
*
*
* #### async
*
* When calling `raise` from `async` you should **always** call `await`, otherwise `raise` can leak out of its scope.
*
*
* ```kotlin
* suspend fun main() {
* val errorA = "ErrorA"
* val errorB = "ErrorB"
* coroutineScope {
* effect {
* val fa = async { raise(errorA) }
* val fb = async { raise(errorB) }
* fa.await() + fb.await()
* }.fold({ error -> error shouldBeIn listOf(errorA, errorB) }, { fail("Int can never be the result") })
* }
* }
* ```
*
*
* #### launch
*
*
* ```kotlin
* suspend fun main() {
* val errorA = "ErrorA"
* val errorB = "ErrorB"
* val int = 45
* effect {
* coroutineScope {
* launch { raise(errorA) }
* launch { raise(errorB) }
* int
* }
* }.fold({ fail("Raise can never finish") }, { it shouldBe int })
* }
* ```
*
*
* #### Strange edge cases
*
* **NOTE**
* Capturing `raise` into a lambda, and leaking it outside of `Effect` to be invoked outside will yield unexpected results.
* Below we capture `raise` from inside the DSL, and then invoke it outside its context `Raise`.
*
*
*
* ```kotlin
* effect Unit> {
* suspend { raise("error") }
* }.fold({ }, { leakedRaise -> leakedRaise.invoke() })
* ```
*
* The same violation is possible in all DSLs in Kotlin, including Structured Concurrency.
*
* ```kotlin
* val leakedAsync = coroutineScope Deferred> {
* suspend {
* async {
* println("I am never going to run, until I get called invoked from outside")
* }
* }
* }
*
* leakedAsync.invoke().await()
* ```
*
*/
public typealias Effect = suspend Raise.() -> A
@Suppress("NOTHING_TO_INLINE")
public inline fun effect(@BuilderInference noinline block: suspend Raise.() -> A): Effect = block
/** The same behavior and API as [Effect] except without requiring _suspend_. */
public typealias EagerEffect = Raise.() -> A
@Suppress("NOTHING_TO_INLINE")
public inline fun eagerEffect(@BuilderInference noinline block: Raise.() -> A): EagerEffect = block
public suspend fun Effect.merge(): A = merge { invoke() }
public fun EagerEffect.merge(): A = merge { invoke() }
public suspend fun Effect.get(): A = merge()
public fun EagerEffect.get(): A = merge()
© 2015 - 2025 Weber Informatics LLC | Privacy Policy