Skip to content
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

Merged
merged 5 commits into from
May 6, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions content/docs/learn/typed-errors/own-error-types.md
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).
Copy link
Member

@nomisRev nomisRev Apr 17, 2023

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.

Let's create great docs for Arrow together!

:::
170 changes: 77 additions & 93 deletions content/docs/learn/typed-errors/working-with-typed-errors.md
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

Expand Down Expand Up @@ -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.
Copy link
Contributor

@myuwono myuwono Apr 18, 2023

Choose a reason for hiding this comment

The 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
" section? This is the same code I put in kotlinlang https://kotlinlang.slack.com/archives/C0B9K7EP2/p1680694416774339?thread_ts=1680388003.721149&cid=C0B9K7EP2. Feel free to modify this as appropriate.. Notice that I've also used sealed class as the failure case. I noticed most production usecases that we've encountered uses sealed classes as well to model the failures. I'm not going to be surprised if many users out there also do the same. I hope that can give something more concrete for users.

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 either { }, i.e. explicitly pointing the docs to using recover ({ eitherValue.bind() }) { ... } instead of eitherValue.mapLeft { }.bind(). The sooner users use recover (...) { } the easier they can transition to context receivers style.

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
}

Copy link
Member Author

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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).