Emanuel Moecklin
07/10/2023, 4:16 PMRaise
DSL over the older either
one, moving from something like
suspend fun findUser(userId: Int): Either<UserNotFound, User> = either {
// success case
anotherFunction(userId).bind()
User(1)
// failure case
UserNotFound(userId).left().bind<User>()
}
to
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
@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?simon.vergauwen
07/10/2023, 4:52 PMsimon.vergauwen
07/10/2023, 4:53 PMRaise
DSL 😁franztesca
07/10/2023, 5:23 PMEither
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 , 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.CLOVIS
07/10/2023, 5:26 PMRaise
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.Youssef Shoaib [MOD]
07/10/2023, 5:26 PMCLOVIS
07/10/2023, 5:28 PMYoussef Shoaib [MOD]
07/10/2023, 5:34 PMwithError
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.simon.vergauwen
07/10/2023, 6:09 PMFlow
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.simon.vergauwen
07/10/2023, 6:10 PMYoussef Shoaib [MOD]
07/10/2023, 6:11 PMsimon.vergauwen
07/10/2023, 6:12 PMInt | 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.Youssef Shoaib [MOD]
07/10/2023, 6:15 PMRaise<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
typefranztesca
07/10/2023, 6:25 PMFlow<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
)
public interface EitherFlow<out F, out V> {
public suspend fun collect(collector: FlowCollector<V>): Either<F, Unit>
}
And we have an eitherFlow builder
eitherFlow {
emit(42)
if (shouldFail) raise(Failure)
}
and it blends in with the either
builder:
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.CLOVIS
07/10/2023, 8:59 PMFlow<Either>
without issuessimon.vergauwen
07/11/2023, 6:19 AMJames Yox
07/12/2023, 4:50 AMmapEither
(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.Emanuel Moecklin
07/12/2023, 5:01 PMRaise
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 😉
CLOVIS
07/12/2023, 8:16 PMchecked exceptions are considered bad practiceIt'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.CLOVIS
07/12/2023, 8:17 PMCLOVIS
07/12/2023, 8:18 PMIOException
is checked.