Recently I've become a fan of explicit error handl...
# functional
n
Recently I've become a fan of explicit error handling using Result<V, E> (using https://github.com/michaelbull/kotlin-result). Yesterday I was shocked by the thought that handling errors this way somehow reminds me of Java's checked exceptions, with different syntax ๐Ÿ˜ Is this analogy really correct? EDIT: I know that in Java some things are not possible like nice chaining of function calls. But in Kotlin it would be possible because of the language's "almost everything is an expression" paradigm.
๐Ÿ‘๐Ÿผ 1
๐Ÿ‘ 3
r
It's similar in that way but from the point of view of ergonomics is worse than checked exceptions because you no longer use regular syntax to deal with the result, instead you have to deal with a wrapped value. With context receivers and arrow 2 you can make it behave like checked exceptions:
Copy code
context(Raise<String>)
fun foo(): Int = raise("error")
vs
Copy code
fun foo(): Result<Int, String> = Result.Failure("error")
raise is like throwing exception but you throw typed values which get caught by the receiver scope providing and alternative return to the function.
n
I haven't seen this raise() construct yet. At first look it seems similar to declared unchecked exceptions without the restriction of they must inherit from
RuntimeException
... EDIT: As I see raise() is still in development unfortunately, I'm looking for the best solution for Kotlin 1.8.0 that is already available :|
r
Raise
is a rename and rework of the internal for what is now available as
EffectScope
in the current Arrow stable release. https://arrow-kt.io/docs/apidocs/arrow-core/arrow.core.continuations/-effect/
If you want to use it today you could express the code above as:
Copy code
fun EffectScope<String>.foo(): Int = shift("error")
n
Nice, thanks. I will try it instantly :)
r
Actually I think
Raise
got recently backported so it may already be available as Raise
n
Copy code
fun EffectScope<String>.foo(): Int = shift("error")
I will try it for sure, although it seems to be a huge restriction that the extension receiver is "used up" by this construct. I hope that multi-platform context receivers will land in Kotlin 1.9 at last...
s
Raise
is available in
1.1.6-alpha.28
and will be released in
1.2.0
in 3-ish weeks. It is indeed a bit restrictive, but luckily you can still return
Either<String, Int>
when you need to free up the receiver, and then mix it back into
Raise<String>
using
bind
when you need too. These patterns mix seamlessly in Arrow, so you can pick and choose what you need when you need it.
Copy code
fun EffectScope<String>.userId(): UserId = 1

fun Repo.fetch(id: UserId): Either<String, User> =
  shift("user with $id not found")

fun EffectScope<String>.program(): Unit {
  val id = userId()
  val user = Repo.fetch(id).bind()
  println(user)
}
t
For your original question. Yes, Result and checked exceptions try to solve the same problem. If a function can fail, your function actually has 2 return values (a good one, and a failure one). Result and checked exceptions both model this fact (making it part of the function signature, and make sure a higher context (the caller) can decide what needs to happen if it fails). In that sense, there is nothing wrong with checked exceptions. The API of checked exceptions isnt the best one though, which is why it was abandoned.
n
from the point of view of ergonomics is worse than checked exceptions because you no longer use regular syntax to deal with the result, instead you have to deal with a wrapped value.
Wouldn't it be better to support some kind of checked failures (similar to checked exceptions) with a better syntax instead of the fairly sophisticated implementation of
raise()
(that is based on
RaiseCancellationException
under the hood anyway)? What do you think about the following if the language would support it with a hypothetical
throws
declaration and
throw()
function:
Copy code
sealed interface FetchError
object UserNotFound: FetchError
data class DatabaseError(val cause: RuntimeException): FetchError

throws(FetchError)
fun Repo.fetch(id: UserId): User =
  try {
    findByIdOrNull(id)
  } catch (e: RuntimeException) {
    throw(DatabaseError(e))
  }
    ?: throw(UserNotFound)

fun program() {
  val user = try {
    repo.fetch(id)
  } catch (e: UserNotFound) { // It would be required to handle all errors
    ...
  } catch (e: FetchError) { // This would catch the rest, in this example only DatabaseError
    ...
  }

  println(user)
}
EDIT: of course this is just a quick idea, eg. it would be better to use other names for
throws
,
throw()
and
catch() {}
like
raises
,
raise()
and
handle() {}
. Please note that I'm not a functional expert, so I may miss some fundamental problem with my idea :)
s
I'm not sure I agree that
Raise
has a fairly sophisticated implementation ๐Ÿ˜… but I may be biased as the author. Regardless of that, of course would having first-class support in the language be awesome. I would argue that this comes very close though, but if the language would every adopt something like that we'd drop
Raise
in a heartbeat in favor of it.
EDIT: of course this is just a quick idea, eg. it would be better to use other names for
throws
,
throw()
and
catch() {}
like
raises
,
raise()
and
handle() {}
.
Translating your snippets to context receivers.
Copy code
typealias FetchErrors = Raise<FetchError>

sealed interface FetchError
object UserNotFound: FetchError
data class DatabaseError(val cause: RuntimeException): FetchError

context(FetchErrors)
fun Repo.fetch(id: UserId): User =
  try {
    findByIdOrNull(id)
  } catch (e: RuntimeException) {
    raise(DatabaseError(e))
  }
    ?: raise(UserNotFound)

fun program() {
  val user = effect {
    repo.fetch(id)
  }.fold({ e: FetchError ->
    when(e) {
      is UserNotFound -> ...
      is DatabaseError -> ...
    }
  }, ::println)
}
Is arguably comes extremely close without first-class support. The first function is unchanged, and only the place where it provides the handlers is a bit different.
n
You are right, they are very similar ๐Ÿค” After some thinking I have to say that your implementation is very nice, thanks for it ๐Ÿ˜‰ (Besides, it's amazing that this is possible with Kotlin!) (Just one small typo: in your example
throws(...)
should be
context(...)
, if maybe somebody finds this great conversation in the future ๐Ÿ˜„)
s
Thanks for catching the typo ๐Ÿ˜œ I updated the snippet. Thank you, glad you like it โ˜บ๏ธ I find it amazing that we have these capabilities in Kotlin! I've been using the language for so long, and I keep loving it more and more as time goes by ๐Ÿ˜
n
@simon.vergauwen, may I have one last technical question: why continuations+coroutines are used in the implementation of Effect/raise(), instead of just using exceptions to "raise" them? (Maybe it would not be possible to implement it using exceptions at all, so I'm just curious.)
s
Not sure what package you're looking at, https://github.com/arrow-kt/arrow/blob/b5ab597da6949c74221ae589f3e898db8d25c591/ar[โ€ฆ]/core/arrow-core/src/commonMain/kotlin/arrow/core/raise/Fold.kt. In the end it is just being implemented with exceptions, I explored a lot of different implementations and this is the only one that makes sense to me in Kotlin. Sadly it cost us 3 different attempts across 0.13.0, 1.0.0 and now 2.0.0. Luckily all source compatible, so migrating is/was (almost) effortless. And can easily be facilitated by automated steps. off-topic: I'm also investigating OpenRewrite for more drastic migration like Validated -> Either. TL;DR on the design document: It's possible to implement this pattern without exceptions but that breaks
try { } finally { }
due to how Coroutines are implemented in the language.
CancellationException
is another restriction since otherwise it doesn't behave correctly in the face of Kotlin Coroutines, which is difference from
0.13.0
to
1.0.0
. Using Coroutines/`@RestrictSuspension` offered a nice (mostly) theoretical benefit, and the implementation I shared uses simply exceptions which is the difference from
1.0.0
to
2.0.0
(
or 1.2.0
new package.) I'm planning to put this in a more lengthy document, and with code examples etc in an appendix on the website later this year.
n
I see, thanks. (I looked at the "current" 1.1.6-alpha.36 implementation in
arrow.core.continuations.DefaultEffect
where
start/suspendCoroutineUninterceptedOrReturn()
is used.)
s
Sorry @Norbi, I was still editing my message. Slack freaked out when I was typing and had to send/edit halfway through.
343 Views