-
Notifications
You must be signed in to change notification settings - Fork 22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improvements to typed errors docs #173
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
--- | ||
description: Writing your own DSLs with Raise. | ||
sidebar_position: 5 | ||
--- | ||
|
||
# Creating your own error wrappers | ||
|
||
`Raise` is a powerful tool that allows us to create our own DSLs to raise typed errors. | ||
It easily allows integration with existing libraries and frameworks that offer similar data types like `Either` or even your own custom types. | ||
For example, let's take a popular ADT often used in the front end, a type that models `Loading`, `Content`, or `Failure`, often abbreviated as `LCE`. | ||
|
||
<!--- INCLUDE | ||
import arrow.core.raise.Raise | ||
import arrow.core.raise.ensure | ||
import arrow.core.raise.recover | ||
import io.kotest.matchers.shouldBe | ||
import kotlin.experimental.ExperimentalTypeInference | ||
--> | ||
```kotlin | ||
sealed interface Lce<out E, out A> { | ||
object Loading : Lce<Nothing, Nothing> | ||
data class Content<A>(val value: A) : Lce<Nothing, A> | ||
data class Failure<E>(val error: E) : Lce<E, Nothing> | ||
} | ||
``` | ||
|
||
## Basic functionality | ||
|
||
Let's say that once a `Failure` or `Loading` case is encountered, we want to short-circuit and not continue with the computation. | ||
It's easy to define a `Raise` instance for `Lce` that does just that. We'll use the composition pattern to do this **without** context receivers. | ||
Since we need to _raise_ both `Lce.Loading` and `Lce.Failure`, our `Raise` instance will need to be able to `raise` `Lce<E, Nothing>`, and we wrap that in a `LceRaise` class. | ||
Within that class, a `bind` function can be defined to short-circuit any encountered `Failure` or `Loading` case or otherwise return the `Content` value. | ||
|
||
```kotlin | ||
@JvmInline | ||
value class LceRaise<E>(val raise: Raise<Lce<E, Nothing>>) : Raise<Lce<E, Nothing>> by raise { | ||
fun <A> Lce<E, A>.bind(): A = when (this) { | ||
is Lce.Content -> value | ||
is Lce.Failure -> raise.raise(this) | ||
Lce.Loading -> raise.raise(Lce.Loading) | ||
} | ||
} | ||
``` | ||
|
||
All that is required now is a DSL function. We can use the `recover` or `fold` function to summon an instance of `RaiseLce<E, Nothing>` from the `Raise` type class. | ||
We wrap the `block` in an `Lce.Content` value and return any encountered `Lce<E, Nothing>` value. We can call `block` by wrapping `Raise<Lce<E, Nothing>>` in `LceRaise`. | ||
|
||
```kotlin | ||
@OptIn(ExperimentalTypeInference::class) | ||
inline fun <E, A> lce(@BuilderInference block: LceRaise<E>.() -> A): Lce<E, A> = | ||
recover({ Lce.Content(block(LceRaise(this))) }) { e: Lce<E, Nothing> -> e } | ||
``` | ||
|
||
We can now use this DSL to compose our computations and `Lce` values in the same way as we've discussed above in this document. | ||
Furthermore, since this DSL is built on top of `Raise`, we can use all the functions we've discussed above. | ||
|
||
```kotlin | ||
fun example() { | ||
lce { | ||
val a = Lce.Content(1).bind() | ||
val b = Lce.Content(1).bind() | ||
a + b | ||
} shouldBe Lce.Content(2) | ||
|
||
lce { | ||
val a = Lce.Content(1).bind() | ||
ensure(a > 1) { Lce.Failure("a is not greater than 1") } | ||
a + 1 | ||
} shouldBe Lce.Failure("a is not greater than 1") | ||
} | ||
``` | ||
<!--- KNIT example-typed-errors-16.kt --> | ||
<!--- TEST assert --> | ||
|
||
If we'd used _context receivers_, defining this DSL would be even more straightforward, and we could use the `Raise` type class directly. | ||
|
||
```kotlin | ||
context(Raise<Lce<E, Nothing>>) | ||
fun <E, A> Lce<E, A>.bind(): A = when (this) { | ||
is Lce.Content -> value | ||
is Lce.Failure -> raise(this) | ||
Lce.Loading -> raise(Lce.Loading) | ||
} | ||
|
||
inline fun <E, A> lce(@BuilderInference block: Raise<Lce<E, Nothing>>.() -> A): Lce<E, A> = | ||
recover({ Lce.Content(block(this)) }) { e: Lce<E, Nothing> -> e } | ||
``` | ||
|
||
## Reflections on `Failure` | ||
|
||
The reason to choose `Lce<E, Nothing>` as type for `Failure` allows for a DSL that has multiple errors. | ||
Let's consider now a type similar to `Lce`, but with additional states which are not considered success. | ||
|
||
```kotlin | ||
DialogResult<out T> | ||
├ Positive<out T>(value: T) : DialogResult<T> | ||
├ Neutral : DialogResult<Nothing> | ||
├ Negative : DialogResult<Nothing> | ||
└ Cancelled: DialogResult<Nothing> | ||
``` | ||
|
||
We can now not really conveniently provide `Raise` over the _flat_ type `DialogResult`, and are kind-of forced to use `DialogResult<Nothing>`. However, if we stratify our type differently, | ||
|
||
```kotlin | ||
DialogResult<out T> | ||
├ Positive<out T>(value: T) : DialogResult<T> | ||
└ Error : DialogResult<Nothing> | ||
├ Neutral : Error | ||
├ Negative : Error | ||
└ Cancelled: Error | ||
``` | ||
|
||
We can again benefit from `Raise<DialogResult.Error>`, and the reason that this is **much** more desirable, it that you can now also interop with `Either`! | ||
|
||
```kotlin | ||
dialogResult { | ||
val x: DialogResult.Positive(1).bind() | ||
val y: Int = DialogResult.Error.left().bind() | ||
x + y | ||
} | ||
``` | ||
|
||
That can be useful if you need to for example want to _accumulate errors_, you can now benefit from the default behavior in Kotlin. | ||
|
||
```kotlin | ||
fun dialog(int: Int): DialogResult<Int> = | ||
if(int % 2 == 0) DialogResult.Positive(it) else Dialog.Neutral | ||
|
||
val res: Either<NonEmptyList<DialogResult.Error>, NonEmptyList<Int>> = | ||
listOf(1, 2, 3).mapOrAccumulate { i: Int -> | ||
dialog(it).getOrElse { raise(it) } | ||
} | ||
|
||
dialogResult { | ||
res.mapLeft { ... }.bind() | ||
} | ||
``` | ||
|
||
:::info Further discussion | ||
|
||
This section was created as a response to | ||
[this issue in our repository](https://github.com/arrow-kt/arrow-website/issues/161). | ||
Let's create great docs for Arrow together! | ||
|
||
::: |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,42 +1,95 @@ | ||
--- | ||
description: Working, recovering, and accumulating errors in a typed and concise way. | ||
sidebar_position: 1 | ||
--- | ||
|
||
# Working with typed errors | ||
|
||
<!--- TEST_NAME TypedErrorsTest --> | ||
|
||
Working with typed errors offers a few advantages over using exceptions: | ||
_Typed errors_ refer to a technique from functional programming in which we | ||
make _explicit_ in the signature (or _type_) the potential errors that may | ||
arise during the execution of a piece of code. This is not the case when using | ||
exceptions, which any documentation is not taken into account by the compiler, | ||
leading to a defensive mode of error handling. | ||
|
||
- **Type Safety:** Typed errors allow the compiler to find type mismatches early, making it easier to catch bugs before they make it to production. However, with exceptions, the type information is lost, making it more difficult to detect errors at compile-time. | ||
:::info Media resources | ||
|
||
- **Predictability:** When using typed errors, the possible error conditions are explicitly listed in the type signature of a function. This makes it easier to understand the possible error conditions and write tests covering all error scenarios. | ||
- [_Functional Error Handling - A Practical Approach_](https://kotlindevday.com/videos/functional-error-handling-a-practical-approach-bas-de-groot/) by Bas de Groot | ||
- [_Exception handling in Kotlin with Arrow_](https://www.youtube.com/watch?v=ipF540mBG9w) by Ramandeep Kaur | ||
- [_Por qué no uso excepciones en mi código_](https://www.youtube.com/watch?v=8WdprhzmQe4) by Raúl Raja and [Codely](https://codely.com/) | ||
serras marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
- **Composability:** Typed errors can be easily combined and propagated through a series of function calls, making writing modular, composable code easier. With exceptions, ensuring errors are correctly propagated through a complex codebase can be difficult. | ||
::: | ||
|
||
- **Performance:** Exception handling can significantly impact performance, especially in languages that don't have a dedicated stack for exceptions. Typed errors can be handled more efficiently as the compiler has more information about the possible error conditions. | ||
## Concepts and types | ||
|
||
In summary, typed errors provide a more structured, predictable, and efficient way of handling errors and make writing high-quality, maintainable code easier. | ||
In the rest of the documentation we often refer to a few concepts related to error handling. | ||
|
||
:::info Media resources | ||
:::note Logical Failure vs. Real exceptions | ||
|
||
- [_Functional Error Handling - A Practical Approach_](https://kotlindevday.com/videos/functional-error-handling-a-practical-approach-bas-de-groot/) by Bas de Groot | ||
- [_Exception handling in Kotlin with Arrow_](https://www.youtube.com/watch?v=ipF540mBG9w) by Ramandeep Kaur | ||
- [_Por qué no uso excepciones en mi código_](https://www.youtube.com/watch?v=8WdprhzmQe4) by Raúl Raja and [Codely](https://codely.com/) | ||
We use the term _logical failure_ to describe a situation not deemed as successful in your domain, but that it's still within the realms of that domain. | ||
For example, if you are implementing a repository for users, not finding a user for a certain query is a logical failure. | ||
|
||
In contrast to logical failures we have _real exceptions_, which are problems, usually technical, which do not fit in the domain. | ||
For example, if the connection to the database suddenly drops, or the connection credentials are wrong. | ||
serras marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
::: | ||
|
||
## Different types | ||
:::note Success and failure | ||
|
||
There are three ways of working with errors (in addition to [_nullability_ and `Option`](../nullable-and-option), which model simple absence of value): | ||
When talking about error handling, we often distinguish between _success_ or _happy path_, and _failure_. | ||
The former represents the case in which everything works as intended, whereas the latter represents a problem. | ||
Depending on the approach, the signature of the function only signals that a failure is possible, | ||
or additionally describes the range of problems that may arise. | ||
|
||
- `Either<E, A>` represents a _computed value_ of _either_ a _logical failure_ of `E` or a _success_ value `A`. | ||
::: | ||
|
||
- `Ior<E, A>` represents a computed value of _either_ a _logical failure_ of `E` or a _success_ value `A` or **both** a success value of `A` together with a _logical failure_ of `E`. | ||
There are two main approaches to representing types in the signature of a function. | ||
Fortunately, Arrow provides a _uniform_ API to working with all of them, which is described in the rest of this section. | ||
|
||
- `Raise<E>` represents a _computation_ that might result in a _logical failure_ of `E`. | ||
The first approach is using a _wrapper type_, in which the return type of your function | ||
is nested within a larger type that provides the choice of error. | ||
In that way the error is represented as a _value_. | ||
For example, the following signature expresses that the outcome of `findUser` is | ||
of type `User` when successful, or `UserNotFound` when a logical failure is raised. | ||
|
||
Below, we'll cover how you can work with these types and the differences and similarities using some examples. These types expose a similar API and allow working in a similar fashion. | ||
``` | ||
fun findUser(id: UserId): Either<UserNotFound, User> | ||
``` | ||
|
||
The Kotlin standard library includes a few wrapper types, but they are all restricted in the information they may include. | ||
Arrow introduces `Either` and `Ior`, both giving the developer the choice of type of logical failures, and reflecting that choice | ||
as their first type parameter. | ||
|
||
| Type | Failure | Simultaneous <br /> success and failure? | Kotlin stdlib. or Arrow? | | ||
|---|---------|------|---| | ||
| `A?` | No information | | <img src="https://upload.wikimedia.org/wikipedia/commons/3/37/Kotlin_Icon_2021.svg" style={{height: '20px'}} /> | | ||
serras marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| `Option<A>` | No information | | <img src="https://upload.wikimedia.org/wikipedia/commons/3/37/Kotlin_Icon_2021.svg" style={{height: '20px'}} /> | | ||
serras marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| `Result<A>` | Of type `Throwable`, <br /> inspection possible at runtime | | <img src="https://upload.wikimedia.org/wikipedia/commons/3/37/Kotlin_Icon_2021.svg" style={{height: '20px'}} /> | | ||
| `Either<E, A>` | Of generic type `E` | | <img src="/img/arrow-brand-icon.svg" style={{height: '20px'}} /> | | ||
| `Ior<E, A>` | Of generic type `E` | ✔️ | <img src="/img/arrow-brand-icon.svg" style={{height: '20px'}} /> | | ||
|
||
|
||
The second approach is describing errors as part of the _computation context_ of the function. | ||
In that case the ability to finish with logical failures is represented by having `Raise<E>` | ||
be part of the context or scope of the function. Kotlin offers two choices here: we can use | ||
an extension receiver or using the more modern context receivers. | ||
|
||
``` | ||
// Raise<UserNotFound> is extension receiver | ||
fun Raise<UserNotFound>.findUser(id: UserId): User | ||
// Raise<UserNotFound> is context receiver | ||
context(Raise<UserNotFound>) fun findUser(id: UserId): User | ||
``` | ||
|
||
:::caution Two examples per code block | ||
|
||
In the examples in this document we use `Either<E, A>` as wrapper type and | ||
`Raise<E>` as extension receiver, with the intention of the reader choosing | ||
their preferred type. Note that the same ideas and techniques apply to the | ||
rest of choices outlined above. | ||
|
||
::: | ||
|
||
## Working with errors | ||
|
||
|
@@ -661,90 +714,21 @@ which follow a short-circuiting approach. | |
|
||
::: | ||
|
||
## Creating your own error wrappers | ||
## Summary | ||
|
||
`Raise` is a powerful tool that allows us to create our own DSLs to raise typed errors. | ||
It easily allows integration with existing libraries and frameworks that offer similar data types like `Either` or even your own custom types. | ||
For example, let's take a popular ADT often used in the front end, a type that models `Loading`, `Content`, or `Failure`, often abbreviated as `LCE`. | ||
At this point we can summarize the advantages that typed errors offer over using exceptions: | ||
|
||
<!--- INCLUDE | ||
import arrow.core.raise.Raise | ||
import arrow.core.raise.ensure | ||
import arrow.core.raise.recover | ||
import io.kotest.matchers.shouldBe | ||
import kotlin.experimental.ExperimentalTypeInference | ||
--> | ||
```kotlin | ||
sealed interface Lce<out E, out A> { | ||
object Loading : Lce<Nothing, Nothing> | ||
data class Content<A>(val value: A) : Lce<Nothing, A> | ||
data class Failure<E>(val error: E) : Lce<E, Nothing> | ||
} | ||
``` | ||
|
||
Let's say that once a `Failure` or `Loading` case is encountered, we want to short-circuit and not continue with the computation. | ||
It's easy to define a `Raise` instance for `Lce` that does just that. We'll use the composition pattern to do this **without** context receivers. | ||
Since we need to _raise_ both `Lce.Loading` and `Lce.Failure`, our `Raise` instance will need to be able to `raise` `Lce<E, Nothing>`, and we wrap that in a `LceRaise` class. | ||
Within that class, a `bind` function can be defined to short-circuit any encountered `Failure` or `Loading` case or otherwise return the `Content` value. | ||
|
||
```kotlin | ||
@JvmInline | ||
value class LceRaise<E>(val raise: Raise<Lce<E, Nothing>>) : Raise<Lce<E, Nothing>> by raise { | ||
fun <A> Lce<E, A>.bind(): A = when (this) { | ||
is Lce.Content -> value | ||
is Lce.Failure -> raise.raise(this) | ||
Lce.Loading -> raise.raise(Lce.Loading) | ||
} | ||
} | ||
``` | ||
|
||
All that is required now is a DSL function. We can use the `recover` or `fold` function to summon an instance of `RaiseLce<E, Nothing>` from the `Raise` type class. | ||
We wrap the `block` in an `Lce.Content` value and return any encountered `Lce<E, Nothing>` value. We can call `block` by wrapping `Raise<Lce<E, Nothing>>` in `LceRaise`. | ||
|
||
```kotlin | ||
@OptIn(ExperimentalTypeInference::class) | ||
inline fun <E, A> lce(@BuilderInference block: LceRaise<E>.() -> A): Lce<E, A> = | ||
recover({ Lce.Content(block(LceRaise(this))) }) { e: Lce<E, Nothing> -> e } | ||
``` | ||
|
||
We can now use this DSL to compose our computations and `Lce` values in the same way as we've discussed above in this document. | ||
Furthermore, since this DSL is built on top of `Raise`, we can use all the functions we've discussed above. | ||
|
||
```kotlin | ||
fun example() { | ||
lce { | ||
val a = Lce.Content(1).bind() | ||
val b = Lce.Content(1).bind() | ||
a + b | ||
} shouldBe Lce.Content(2) | ||
|
||
lce { | ||
val a = Lce.Content(1).bind() | ||
ensure(a > 1) { Lce.Failure("a is not greater than 1") } | ||
a + 1 | ||
} shouldBe Lce.Failure("a is not greater than 1") | ||
} | ||
``` | ||
<!--- KNIT example-typed-errors-16.kt --> | ||
<!--- TEST assert --> | ||
- **Type Safety:** Typed errors allow the compiler to find type mismatches early, making it easier to catch bugs before they make it to production. However, with exceptions, the type information is lost, making it more difficult to detect errors at compile-time. | ||
|
||
If we'd used _context receivers_, defining this DSL would be even more straightforward, and we could use the `Raise` type class directly. | ||
- **Predictability:** When using typed errors, the possible error conditions are explicitly listed in the type signature of a function. This makes it easier to understand the possible error conditions and write tests covering all error scenarios. | ||
|
||
```kotlin | ||
context(Raise<Lce<E, Nothing>>) | ||
fun <E, A> Lce<E, A>.bind(): A = when (this) { | ||
is Lce.Content -> value | ||
is Lce.Failure -> raise(this) | ||
Lce.Loading -> raise(Lce.Loading) | ||
} | ||
- **Composability:** Typed errors can be easily combined and propagated through a series of function calls, making writing modular, composable code easier. With exceptions, ensuring errors are correctly propagated through a complex codebase can be difficult. Patterns like accumulation, which are at your fingertips using typed errors, become quite convoluted using exceptions. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another suggestion to the readme @serras. I've got some feedback from colleagues that when they read this documentation, they were looking to find a code example that illustrate computation chaining for a production use case. I remember something like that used to exist in the old docs about comprehension. Having said that, may I suggest adding a snippet along these lines - perhaps in the "From logical failures context(Raise<UpdateUserFailure>) // this is a different error boundary
suspend fun updateUser(...): User = ...
context(Raise<ProvisioningFailure>)
suspend fun checkManagedStatus(...): Unit = ensure(user.isManaged) {
ProvisioningFailure.NotManaged
}
context(Raise<ProvisioningFailure>)
suspend fun checkProvisioningPolicy(...): Unit = ...
context(Raise<ProvisioningFailure>)
suspend fun provision(): User {
checkManagedStatus(...)
checkProvisioningPolicy(...)
val updatedUser: User = recover({ updateUser(...) }) {
when (it) {
is UpdateUserFailure.UserNotFound -> raise(ProvisioningFailure.InvalidUser)
is UpdateUserFailure.UpdateRejected -> raise(ProvisioningFailure.InvalidUpdate)
}
}
updatedUser
} We can also encourage user to prepare for context receiver accordingly by providing a version of the composition in suspend fun provision(): Either<ProvisioningFailure, User> = either {
checkManagedStatus(...).bind()
checkProvisioningPolicy(...).bind()
val updatedUser: User = recover({ updateUser(...).bind() }) {
when (it) {
is UpdateUserFailure.UserNotFound -> raise(ProvisioningFailure.InvalidUser)
is UpdateUserFailure.UpdateRejected -> raise(ProvisioningFailure.InvalidUpdate)
}
}
updatedUser
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the very concrete example, @myuwono. We got similar feedback from other people, which only reinforces the fact that our docs are lacking in that respect. I made some changes in this document to clarify the situation, do you think the read better now? I'm hesitant, though, to make the introduction even longer. Maybe we should have a full worked-out example where we describe these ideas, like how to design your error hierarchy, the different boundaries, and so on? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a brilliant idea. |
||
|
||
inline fun <E, A> lce(@BuilderInference block: Raise<Lce<E, Nothing>>.() -> A): Lce<E, A> = | ||
recover({ Lce.Content(block(this)) }) { e: Lce<E, Nothing> -> e } | ||
``` | ||
- **Performance:** Exception handling can significantly impact performance, especially in languages that don't have a dedicated stack for exceptions. Typed errors can be handled more efficiently as the compiler has more information about the possible error conditions. | ||
|
||
# Conclusion | ||
In summary, typed errors provide a more structured, predictable, and efficient way of handling errors and make writing high-quality, maintainable code easier. | ||
|
||
Working with typed errors in Kotlin with Arrow is a breeze. We can use the `Either` type to represent a value that can either be a success or a failure, and we can use the `Raise` DSL to raise typed errors without _wrappers_. | ||
We can use the `Either` type to represent a value that can either be a success or a failure, and we can use the `Raise` DSL to raise typed errors without _wrappers_. | ||
Since all these functions and builders are built on top of `Raise`, they all seamlessly work together, and we can mix and match them as we please. | ||
|
||
If you have any questions or feedback, please reach out to us on [Slack](https://slack-chats.kotlinlang.org/c/arrow) or [Github](https://github.com/arrow-kt/arrow/issues). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Aha, great!
I wanted to add some more details to this doc, we can do it here or in a subsequent PR.
Lce
to work overLceRaise<Any?>
and expose it overRaise<E>
to make it interopt with all other DSLs. See Quiver, https://github.com/cashapp/quiver/pull/26/files#diff-2a7ca3e3751a2611788369c9a4c63f27ee97ed2403d24e58d1476e01f57937b0R48DialogResult
can just have abind()
function defined overRaise<E>
when we have context receivers