Joost Klitsie
06/28/2024, 10:19 AMCLOVIS
06/28/2024, 12:33 PMsuspend fun run(): Result<Something> = fetchLoggedInUserUseCase.run()
> .flatMap { user -> fetchSomeSecretUseCase.run(user) }
> .flatMap { secret -> someRepository.fetchSomethingWithSecret(secret) }
> This, in my humble opinion, is extremely readable.
It is readable, but I wouldn't say 'extremely'. There's a lot of boilerplate here. But thankfully, in Kotlin, we can do much better, for example, with #arrow :
suspend fun run() = either {
val user = fetchLoggedInUserUseCase().run()
val secret = fetchSomeSecretUseCase().run(user)
someRepository.fetchSomethingWithSecret(secret)
}
This is the same code, with the same typed error handling. This one, I would agree is extremely readable πCLOVIS
06/28/2024, 12:36 PMfun recoverExample(initialResult: Result<String>): Result<String>
= initialResult.recover { "Will be returned if initialResult is failure" }
If you're using coroutines, and you seem to be, there is a bug in this code. It should be
suspend fun recoverExample(initialResult: Result<String>): Result<String> =
initialResult.recover {
currentCoroutineContext().ensureActive()
"Will be returned if initialResult is failure"
}
See https://betterprogramming.pub/the-silent-killer-thats-crashing-your-coroutines-9171d1e8f79bCLOVIS
06/28/2024, 12:37 PMflatRecover
Joost Klitsie
06/28/2024, 12:39 PMCLOVIS
06/28/2024, 12:41 PM``` suspend fun fetchSomethingWithSecret(
secret: String,
): Result<Something> = memoryCache.get(secret).flatRecover {
database.get(secret).flatRecover {
network.get(secret).onSuccess { something ->
database.set(secret, something)
}
}.onSuccess { stuff ->
memoryCache.set(secret, something)
}
}```
OMG. It is like functional programming and awesomeness had a baby.Same code, with Arrow:
suspend fun fetchSomethingWithSecret(
secret: String,
) = recover(
block = {
memoryCache.get(secret)
},
recover = {
val response = network.get(secret)
database.set(secret, response)
memoryCache.set(secret, response)
response
}
)
Joost Klitsie
06/28/2024, 12:42 PMJoost Klitsie
06/28/2024, 12:43 PMCLOVIS
06/28/2024, 12:43 PMThe problem there is that is hard to tell when a function throws an error.I kept the code exactly the same as yours. Both versions return a result object.
Joost Klitsie
06/28/2024, 12:43 PMCLOVIS
06/28/2024, 12:44 PMrecover
and flatRecover
will execute the recovering code, which is a leak. Code should not run after cancellation.Joost Klitsie
06/28/2024, 12:45 PMCLOVIS
06/28/2024, 12:45 PMrunCatching
, then. How do you create your result objects?Joost Klitsie
06/28/2024, 12:47 PMfun fetchDataFromMemory(): Result<String> = memoryCache.get()?.let(::success) ?: failure(NotFoundException("stuff not found"))
example which I do not like:
fun fetchDataFromMemory(): String = memoryCache.get() ?: throw NotFoundException("Stuff not found)
Joost Klitsie
06/28/2024, 12:47 PMCLOVIS
06/28/2024, 12:49 PMJoost Klitsie
06/28/2024, 12:50 PMCLOVIS
06/28/2024, 12:51 PMJoost Klitsie
06/28/2024, 12:52 PMswanti
06/28/2024, 12:56 PMsuspend fun run(): Result<Something> = fetchLoggedInUserUseCase.run()
.flatMap { user -> fetchSomeSecretUseCase.run(user) }
.flatMap { secret -> someRepository.fetchSomethingWithSecret(secret) }
Wouldn't it be nicer to do something like:
suspend fun run(): Result<Something> = runCatching {
val user = fetchLoggedInUserUseCase.run().getOrThrow()
val secret = fetchSomeSecretUseCase.run(user).getOrThrow()
someRepository.fetchSomethingWithSecret(secret).getOrThrow()
}
I think getOrThrow
is nicer because it doesn't add indentation and it's very clear what it does.CLOVIS
06/28/2024, 12:59 PMJoost Klitsie
06/28/2024, 12:59 PMCLOVIS
06/28/2024, 1:00 PM.bind
is only needed to convert between Either
and the Raise
DSL. If your entire call stack uses the Raise
DSL, you never need to convert, and thus you never need to call .bind
Joost Klitsie
06/28/2024, 1:01 PMJoost Klitsie
06/28/2024, 1:01 PMCLOVIS
06/28/2024, 1:02 PMResult
functions by combining multiple Result
functions. In my example, I am creating a Raise
function by combining multiple Raise
functions.CLOVIS
06/28/2024, 1:03 PMJoost Klitsie
06/28/2024, 1:03 PMinterface FetchSomeSecretUseCase {
suspend fun run(user: User): Either<Exception, String>
}
Joost Klitsie
06/28/2024, 1:03 PMCLOVIS
06/28/2024, 1:04 PMsuspend fun run(user: User): Either<Exception, String>
then yes, you have to use .bind()
However, you can write
suspend fun Raise<Exception>.run(user: User): String
or
context(Raise<Exception>)
suspend fun run(user: User): String
and then you don't have to use .bind()
CLOVIS
06/28/2024, 1:07 PMsuspend fun Raise<Exception>.run(): Something {
val user = fetchLoggedInUserUseCase().run()
val secret = fetchSomeSecretUseCase().run(user)
return someRepository.fetchSomethingWithSecret(secret)
}
so it's clear they all use Raise
swanti
06/28/2024, 1:08 PMsuspend
function, but the point still stands, you can create a function called runSuspendCatching
. Regarding creating exceptions, you only create one, which doesn't seem like a huge performance hit.CLOVIS
06/28/2024, 1:09 PMCLOVIS
06/28/2024, 1:09 PMswanti
06/28/2024, 1:18 PMRaise<Exception>
extension function would limit what you can do. You can't have a List<T>.Raise<Exception>.run()
function or whatever. But maybe there is a different way to handle cases like those.CLOVIS
06/28/2024, 1:19 PMcontext(Raise<Exception>)
fun List<T>.run() { β¦ }
If you're on the JVM or Android, you can enable the context receiver prototype, but this isn't yet available for all platforms.Joost Klitsie
06/28/2024, 1:20 PMJoost Klitsie
06/28/2024, 1:21 PMJoost Klitsie
06/28/2024, 1:21 PMCLOVIS
06/28/2024, 1:23 PMResult
to be part of your pattern. Arrow's functions have to have a receiver on Raise
to be part of the pattern, it's the same.
Of course, you have to create a conversion function to enter either world. In the Result
world, you would use something like
runCatching {
yourCodeβ¦
}.recover { coroutineContext.ensureActive() }
and now you're safe (you're still catching OutOfMemoryError, StackOverflowError, etc though).
In the Arrow world, the function is just called catch
:
catch {
yourCodeβ¦
}
It automatically handles CancellationException and all other nasty things you don't want to catchgildor
07/02/2024, 3:01 AMCLOVIS
07/02/2024, 7:34 AMgildor
07/02/2024, 7:39 AMCLOVIS
07/02/2024, 7:43 AMrunCatching are not better though in terms of performance (slightly worse even)Compared to what?
runCatching
is an inline
function, there's no difference between using it or writing a tryβ¦catch
yourself. The expensive things are the exception constructors and the throw
mechanism.
they do not help with "what to catch"I disagree. Using
Result
(assuming you properly catch what needs to be caught, meaning you don't use runCatching
) and Either
allows separation of concerns: the code that may crash is responsible for catching the exception or representing any other error, and communicates that to the part of the codebase that must deal with it. That second part doesn't need to know anything about the error conditions, it doesn't even need to know which exceptions are possible, because they are typesafely described by the wrapper.
pollute code a lotWell, we're working on it π
gildor
07/02/2024, 7:54 AMCompared to what?It creates exception anyway, with stacktrace. Also creates one more, non value, class Failure to wrap it. Not a big deal, but stillis anrunCatching
functioninline
gildor
07/02/2024, 7:55 AMwe're working on itYeah, let's see how Union types for exception will affect it. But I don't think Result/Either will ever be good without language support
CLOVIS
07/02/2024, 7:56 AMfun logIn(username: String, password: String, passwordConfirmation: String): User {
require(username.isNotBlank()) { "Username shouldn't be empty: '$username'" }
require(password.length in 8..64) { "Password is not the correct size" }
require(password == passwordConfirmation) { "Passwords don't match" }
β¦
}
with the current state of Arrow with context parameters:
sealed class LogInError {
data object UsernameShouldntBeBlank : LogInError()
data object PasswordIncorrectSize : LogInError()
data object PasswordsDoNotMatch : LogInError()
}
context(_: Raise<UserError>)
fun logIn(username: String, password: String, passwordConfirmation: String): User {
ensure(username.isNotBlank()) { UsernameShouldntBeBlank }
ensure(password.length in 8..64) { PasswordIncorrectSize }
ensure(password == passwordConfirmation) { PasswordsDoNotMatch }
β¦
}
I don't think it's possible to do much better than that for code pollution⦠The only change to the function itself is adding the context
declaration to declare the error type, which seems reasonable to me.gildor
07/02/2024, 7:56 AMThe expensive things are the exception constructors and theYes, but you do not get rid of them if replace with Result And don't forget, Exceptions have stacktrace, yes we pay for them, but also they provide a lot of debug information, not available for algebraic typesmechanism.throw
gildor
07/02/2024, 7:57 AMCLOVIS
07/02/2024, 8:03 AMerror sealed class LogInError {
error data object UsernameShouldntBeBlank : LogInError()
error data object PasswordIncorrectSize : LogInError()
error data object PasswordsDoNotMatch : LogInError()
}
fun logIn(username: String, password: String, passwordConfirmation: String): User | LogInError {
if (username.isBlank()) return UsernameShouldntBeBlank
if (password.length !in 8..64) return PasswordIncorrectSize
if (password != passwordConfirmation) return PasswordsDoNotMatch
β¦
}
but the worst part is the chaining syntax.
Current Kotlin:
fun foo(i: Int): Bar =
bar(baz(i))
Current Arrow with context parameters:
context(_: Raise<FooError>)
fun foo(i: Int): Bar =
bar(baz(i))
Current Arrow with current Kotlin:
fun Raise<FooError>.foo(i: Int): Bar =
bar(baz(i))
Union types prototype:
fun foo(i: Int): Bar | FooError =
baz(i)
!.let { bar(it) }
So far, being a language feature doesn't imply less code pollution π
CLOVIS
07/02/2024, 8:05 AMsame as our own attempts to use ResultThat, I completely agree with.
Result
was never designed for error handling, and it's really inconvenient to use as such.gildor
07/02/2024, 8:13 AMgildor
07/02/2024, 8:14 AMSo far, being a language feature doesn't imply less code pollutionYes, for sure, I just don't really understand all implications yet, so hard to comment on it. Why it may be promising, is improvement for marking core exceptions as error object
gildor
07/02/2024, 8:15 AMfun foo(i: Int): Bar | FooError {
val res = baz(i)!!
return bar(res)
}
CLOVIS
07/02/2024, 8:18 AMfun foo(i: Int): Either<FooError, Bar> = either {
val res = !baz(i)
bar(res)
}
gildor
07/02/2024, 8:20 AMgildor
07/02/2024, 8:21 AMgildor
07/02/2024, 8:22 AM!!
same as with null, not !
but I saw discussion in the issue and potential foo!!!!
Joost Klitsie
07/02/2024, 8:22 AMJoost Klitsie
07/02/2024, 8:22 AMgildor
07/02/2024, 8:22 AMgildor
07/02/2024, 8:24 AMJoost Klitsie
07/02/2024, 8:24 AMfun getSomething(): Something | SomeError
= getUser()!.let { user ->
getSecret(user)
}!.let { secret ->
getSomethingWithSecret(secret)
}
gildor
07/02/2024, 8:24 AMgildor
07/02/2024, 8:25 AMCLOVIS
07/02/2024, 8:26 AMfun getSomething(): Something =
getSomethingWithSecret(getSecret(getUser()))
Arrow (exact same benefits):
context(_: Raise<SomeError>)
fun getSomething(): Something =
getSomethingWithSecret(getSecret(getUser()))
Currently, this proposal is just a prototype. But if it gets implemented like this, I'm definitely not using itgildor
07/02/2024, 8:34 AMgildor
07/02/2024, 8:35 AMCLOVIS
07/02/2024, 9:04 AMBut Raise<SomeError> doesn't allow to show different types of errors thoughYou can
context(_: Raise<SomeError>, _: Raise<SomeOtherError>)
to emulate union typesgildor
07/02/2024, 9:05 AM