A bit of a blasphemous question here. I love the `...
# arrow
e
A bit of a blasphemous question here. I love the
Raise
DSL over the older
either
one, moving from something like
Copy code
suspend fun findUser(userId: Int): Either<UserNotFound, User> = either {
    // success case
    anotherFunction(userId).bind()
    User(1)
    // failure case
    UserNotFound(userId).left().bind<User>()
}
to
Copy code
context(Raise<UserNotFound>)
suspend fun findUser(userId: Int): User {
    // success case
    anotherFunction(userId)
    User(1)
    // failure case
    raise(UserNotFound(userId))
}
Looking at the the latter it's pretty much the syntax you'd use when using good old fashioned exceptions like
Copy code
@Throws(UserNotFoundException::class)
suspend fun findUser(userId: Int): User {
    // success case
    anotherFunction(userId)
    return User(1)
    // failure case
    throw UserNotFoundException(userId)
}
Now at the top level I need to deal with the exception instead of dealing with an Either but using
runCatching
makes that fairly simple. So how is the
Raise
solution better than the throws solution? I need to convince a large team of engineers that the former has advantages so need good arguments but tbh I haven't had any convincing ideas yet. Any suggestions?
👍 1
Feel free to ask any follow-up questions if you have any ☺️ And glad to hear you love the
Raise
DSL 😁
f
Personal experience: I tried using
Either
in my project, and after propagating every expected failure (e.g. IO errors, not preconditions or ISE) through the whole stack (adding complexity/noise to the signature of each function/test etc. and adding error classes), I realized that I was handling the failures only in one spot or two and often it was just logging the error and restarting some major component, getting basically zero value from 99% of the propagation logic (which becomes even more painful when working with Flows IMHO). If I could go back, I would use exceptions in my use case and with my code style (maybe it works better for fully functional). I kinda share the feeling at the end of this nice talk

https://www.youtube.com/watch?v=Ed3t4WAe0Co

, which is, use it where it makes a lot of sense (around 5% of the cases), just throw otherwise. I agree that
Raise
is better than checked exceptions, but the question is, do you need checked exceptions in the first place? Also it is possible that the increased ergonomics of Raise vs Either moves the threshold and it is 10-20% instead of 5%, but still, it's not a no-brainer choice IMHO.
c
In my case, I use
Raise
whenever the error is part of the domain (password too small, missing precondition), and exceptions whenever the error is technical (IO). This has allowed me to have very precise feedback to users in the UI, which was previously very difficult to do. I'm not a fan of requiring DTOs of all my error types, but it allows someone using my API to see the entire list of errors I can throw to them.
y
With Raise at least, the propagation is merely a 1-line addition to the function signature, kind of similar to suspend. With better IDE support, you'll also get automatic fixes that add the needed context as an IDE action, so it's gonna get even easier to use Raise
c
^ that's only in the case where you raise the exact same type as the function you're calling. At least in my case, that's rare. I'm hoping https://github.com/arrow-kt/arrow/pull/3059 will help with this.
y
That's a very good point. Yes using
withError
makes that pretty neat because you then only need an
(Error1) -> Error2
function, which you can provide as a lambda or a function reference. Also, if you're all-in on context receivers already, it's trivial to make a
withErrors
function for n number of errors, or to make your own
withDomainErrorHandling
function that provides N different Raise instances and converts them to your domain error type. Only thing that's missing is some way to be able to chain such functions without having a huge amount of nesting. For instance, doing
withError
N times results in 2N or 4N spaces (depending on your indentation), thus you need to define
withErrors
to make that neater. Hopefully, soon we'll have some design for decorators that addresses this issue, because then it'll make this a whole lot neater.
s
@franztesca I agree with Urs (and @CLOVIS), and I've discussed it with him several times. I wrote a section here on the Arrow website documenting it a bit as well. https://arrow-kt.io/learn/typed-errors/working-with-typed-errors/#from-exceptions Typed errors are suggested to be used for domain errors so expected, and not unexpected. You can also see that in my example project, https://github.com/nomisRev/ktor-arrow-example/blob/main/src/main/kotlin/io/github/nomisrev/DomainError.kt. There is very little errors, and only those that I considered important for my domain logic. Combining typed errors with
Flow
can be tricky, and cumbersome and it's something that is also not really elegantly solved in FP. Not sure if it every will. I guess FS2 in Scala comes closest but having
Stream[EitherT[IO, String, ?], A]
is perhaps not that much better than just working with
Flow<Either<String, A>>
. It heavily depends on the use-case if you want
Either
inside of
Flow
too. In that case you might be better off with a custom
sealed
hierarchy.
It's sadly a common learning journey to go overboard with something, and then find a better middleground afterwards. Something that has put off a lot of people learning FP coming from OOP.
y
Re Flows with Raise: once Union types come around, and assuming that the errors in question are discriminated in their hierarchies, could Arrow provide a custom Raise-aware Flow type that allows such error propagation?
s
Union types are non-biased, and not ordered so
Int | String
is the same type as
String | Int
. I think it will prove difficult but if features like union, intersection types land in the language then perhaps there are options we can explore.
y
Yeah I mean once we have proper unions in the language, you can then easily have a
Raise<BackendError | LibraryError>
, and so Arrow could provide a
RaiseFlow<E, R>
that mirrors all the operations of
Flow<R>
, but marks its operations with
Raise<E>
, and so the E can be a union type, thus you won't need to define
RaiseFlow
for n-arity. Perhaps it even can implement the
Flow
interface and present itself as a
Flow<Either<E, R>>
but provide efficient (non-boxing) methods whenever you use the
RaiseFlow
type
f
Usually when working with Flows we want to emit multiple success or terminate with a failure (not emitting failures). Emitting a failure doesn't imply termination and potentially ever
Flow<Either<>>
should we wrapped with
.takeUntil { it is Either.Success }
to ensure termination. It also was requiring manual termination of the flow (with a
return
) or wrapping with the
takeUntil
, which was quite bug-prone. We weren't happy with that. What we ended up doing, is adding an
EitherFlow
(we have our own version of
Either
, which is opinionated for
Failure
and
Success
)
Copy code
public interface EitherFlow<out F, out V> {
    public suspend fun collect(collector: FlowCollector<V>): Either<F, Unit>
}
And we have an eitherFlow builder
Copy code
eitherFlow {
   emit(42)
   if (shouldFail) raise(Failure)
}
and it blends in with the
either
builder:
Copy code
suspend fun myFun() = either {
    myEitherFlow
       .handled() // binds the EitherFlow to the either scope, returns a normal flow
       .map { it + 1 }
       .collect { println(it) }
}
and we were ok with the solution, even if out should be careful not leak a bound
EitherFlow
, and it was a bit cumbersome.
c
@franztesca Interesting, that's not my experience. I do emit errors without terminating all the time, so I just tend to use
Flow<Either>
without issues
s
Yes, it really depends on the use-case. Often when streaming events from Kafka, or Azure I want a never ending stream and want intermediate errors. Similarly for front-end. But when working with files, or other I/O it's fine to just fail.
j
I use Flows with either and ior regularly. My specific needs require rather extreme failure tolerance. I usually opt for the emitting failures route. Like most here have brought up. It's not exactly elegant. I've often speculated that maybe a library could be created to make this easier but never was able to make anything I felt good about. Super open to ideas though. At the very least you can define functions like
mapEither
(map on flow which calls map on either). I have a little collection going but it's not exactly good. The
RaiseFlow
concept seems interesting, but who knows when we'll get union and intersection types. Would be a really fun problem to tackle though.
e
@simon.vergauwen thank you for taking the time to answer my question. Going through some of the documentation you posted, the answer seems to boil down to the fact that checked exceptions are considered bad practice in general and hence aren't part of the Kotlin language. So while the syntax of the
Raise
and the
@Throws
code is "identical", we should not use the latter. As others mentioned, the rule is to use a discriminated union result type for all regular application flow errors and use exceptions only for errors that represent an unexpected state (environment issues, application bugs, preconditions). Another way to decide between these two types of error conditions is whether they should be handled locally or not. According to https://github.com/Kotlin/KEEP/blob/master/proposals/stdlib/result.md#error-handling-style-and-exceptions, exceptions should never be be handled locally:
Exceptions in Kotlin are designed for the failures that usually do not require local handling at each call site. This includes several broad areas — logic and programming errors like index bounds problems and various checks for internal invariants and preconditions, environment problems, out of memory conditions, etc. These failures are usually non-recoverable (or are not supposed to be recovered from) and are handled in some centralized way by logging or otherwise reporting them for troubleshooting, typically terminating application or, sometimes, attempting to restart or to reinitialize an application as a whole or just its failing subsystem.
There are other reasons to use a result type over exceptions but this one seems to be the most convincing one, basically: decades of experience with checked exceptions showed that they are evil so don't use them 😉
c
checked exceptions are considered bad practice
It's worth knowing why: it's not really because of the concept, per se, it's because they're awkward to work with.
try…catch
is a weird keyword, it's not really a function, it doesn't play well with lambdas. On the JVM,
throws
cannot be applied to lambdas, meaning all Java 8+ APIs like Stream cannot use them. On the contrary, context receivers have none of these issues, so
Raise
has the benefits without the downsides.
Also, exceptions in general are slow, which is not great when you want to represent domain errors in an environment that requires high performance.
The problem is also how misused they were. As you can see, runtime exceptions were meant for things that can't be predicted, and checked exceptions for things that could. Yet,
IOException
is checked.
329 Views