What is the Arrow idiomatic way to deal with eithe...
# arrow
p
What is the Arrow idiomatic way to deal with eithers whose left and right have nothing in common in the context of handling errors? Both A and C types have their own independent class hierarchies. Example of the situation described:
Copy code
val theEither: Either<Any, D> = either {

 val x : Either<A, B> = serviceA.something()
 val xValue : B = x.bind()

 val y : Either<C, D> = serviceB.somethingElse(x)
 val yValue : D = x.bind()

 yValue
}
c
Not sure if it’s idiomatic but have been using
mapLeft
to adapt the error to something that works for the current context.
x.mapLeft { it -> OtherThing(it.message) }.bind()
p
Thanks Chris, makes sense. I’m thinking perhaps the best route in this scenarios is to mapLeft all the errors coming from the different eithers into a net new error hierarchy to get rid of the Any and allow the call site to handle the error scenarios with a when/fold.
c
sounds like a solid plan if neither A or C make sense to expose to the callers.
p
I was thinking maybe some of the new errors act as wrappers so the call-site, if needed, can access the actual error via a
val error: A
(or C whichever the case).
Copy code
data class SomeNewError(val description: String, val error: A) : NewHierarchy
c
could work. I just did a small one, two layers of API, ended up translating the lower layer errors to the higher layer ones. My thinking was that the error aspect of the API should provide encapsulation (i.e. callers shouldn’t know about A if that’s internal to the API).
Copy code
// outer API
public sealed interface KeyRingApiError {
    public object NotFound : KeyRingApiError
    public data class Error(val message: String) : KeyRingApiError
    public data class Exception(val cause: Throwable) : KeyRingApiError
}

// inner API (wrapper around native calls)
internal sealed interface KeychainApiError {
    object NotFound : KeychainApiError
    data class Error(val errorCode: Int, val message: String) : KeychainApiError
    data class Exception(val cause: Throwable) : KeychainApiError
}

    private fun keyRingApiError(keychainApiError: KeychainApiError) =
        when (keychainApiError) {
            is KeychainApiError.NotFound -> KeyRingApiError.NotFound
            is KeychainApiError.Exception -> KeyRingApiError.Exception(keychainApiError.cause)
            is KeychainApiError.Error -> KeyRingApiError.Error("${keychainApiError.errorCode}: ${keychainApiError.message}")
        }
imo, that’s one of the problems with exceptions - leaky abstractions, they wander out into calling code…
p
Yeah, that is exactly what was on my mind. I’m with you. My takeaway here is that if for whatever reason multiple domains come together with their own errors (etc.,), the aggregator needs to be careful about leaking those very same errors, they many not make sense or could be better represented in the context of the specific (aggregate )domain at hand.
c
exactly. from the consumer’s perspective, they should be able to look at the method signature and get a pretty good idea of the error footprint. That footprint is magnified if there are nested types, etc - means they have to understand the failure modes of a whole different API as well…
generally the API layers should have higher levels of abstraction - would expect the outer layer to have fewer specific errors (from underlying calls), rather those aggregate to “NeverGonnaWorkError” cuz the details as to why it failed aren’t relevant/actionable to the caller (though maintaining a string version of the cause may be helpful for diagnostics).
p
Agree, in my code base I’ll be adding logs that not only show the errors along the way but also the translations/conversion that happen between the errors as they cross layers.
c
love it ❤️
s
I replied in the context receivers question for an alternative answer there. Wrapping errors, or mapping them across layers is quite common. Although I would personally not discard the "lower level/layer error" when wrapping it in a "higher level/layer error". I would keep it around similar to
Throwable#cause
.
c
@simon.vergauwen agree on wrapping errors. Agree on the concept of keeping around previous errors (error chaining) - there are implementation aspects that are potentially problematic. If the original types are exposed this becomes a leaky abstraction, much like exceptions where a random SQLException shows up in business logic. For example, an overly-determined developer will do:
Copy code
when(val status = callAwesomeStuff()) {
    is Either.Right -> 42
    is Either.Left -> when(status.value) {
        is ErrorX -> when {
            // uuugggghhhh.....
            status.value.causes.any { it is ErrorY } -> 82
        }
        else -> 43
    }
}
imo, chained errors: • are necessary for diagnostic purposes • should be removed from the type system to provide encapsulation and reduce the error-comprehension-footprint. Seems wrong to have implementation-specific errors available.
…by “removed from the type system” - could be capturing the toString() of the chained errors, for example.
147 Views