While the following example is not specifically ab...
# arrow
d
While the following example is not specifically about Arrow I'm still cross-posting it here from the #server. Let's say you propagate errors using the
Either
(or
Result
or whatever) pattern. How do you combine errors from different services/methods to a new one? Example:
Copy code
fun callTwoServices(): Either<???, String> {
  val resultA: Either<ErrorA, Int> = serviceA.call()
  val resultB: Either<ErrorB, Int> = serviceB.call()
  // do something with the results, possibly propagating the errors
  ...
  return Either.Right(myResult)
}
The question is what should be the ??? type? There are two approaches I can think of • Create a sealed class
MyError
with subclasses
MyErrorA
and
MyErrorB
, and map the original
Error*
to new ones. The resulting error type would then be
MyError
. • Create
Error
sealed class already for the
Error*
subclasses and use this
Error
at all left positions. Neither approach seems ideal to me. While the first one looks more correct, it's quite boilerplaty. The second one then quickly leads to a god object (containing unrelated error types). I guess the ideal solution would be to use union type
Either<MyErrorA | MyErrorB, String>
. But since this feature is not in Kotlin yet what is the approach you take in this situation?
y
If you're using
context
receivers, you can use 2 contexts
context(Raise<MyErrorA>, Raise<MyErrorB>)
.
d
I don't use context receivers yet but this would finally provide a strong motivation for me 🙂 So if I understand did correctly, this mechanism would allow me to define the error type "union" throughout my service, and then simply "unpack" the whole stuff in the controller via
recover { ... }
or something?
y
Yes, basically. The idea is to use
context(Raise<>)
everywhere so that you can always raise whatever errors a method needs, and the compiler will enforce that those errors are handled with an appropriate
Raise
instance. Then, in the code that converts between different domain layers, you'd use
withError
. At the very edge of your program, you'll likely use
recover
.
You'll also use
recover
if you want to, well, recover from an error. There's more APIs available, such as
zipOrAccumulate
that allow for error accumulation.
a
you definitely need to create some type of hierarchy of errors; it's true that union types would solve it a bit, but on the other hand it's not perfect either as they don't allow "growing" the potential types of errors without a signature change. Remember that the error type is part of your (public) API when you use Either (as opposed to exceptions) having said so, how I usually combat the "god object" problem is by having different interfaces. For example:
Copy code
interface Error
interface ConnectionOrAuthenticationError: Error
interface ConnectionError: ConnectionOrAuthenticationError {
  data class WrongIP(val address: Address): ConnectionError
  data ...
}
interface AuthenticationError: ConnectionOrAuthenticationError {
  ...
}
stressing what I've said about error types being part of your API, I think that one needs to be a bit lax in what you "promise" as error types, as they may grow (or shrink) in the future. For example, if you say that
callTwoServices
may only throw A or B, but because of implementation details later it's only A or C, you would need to change every usage of that method, which is not ideal
d
Yeah, I'm also inclined to use the structured error-type approach now. Since the sole purpose of my service is to serve an HTTP API I would probably keep the design simple, e,g.
Copy code
sealed interface Error
sealed interface UserError : Error // typically results in to 40x
sealed interface NotFound(val message: String) : UserError
sealed interface Unauthorized : UserError
sealed interface GenericError : Error // for unspecified errors leading to 422
...
This error hierarchy would then be used in all services (unit of business logic) of my microservice (unit of deployment)
Related to this, how do you treat errors as they come across boundaries, e.g. DB -> service1 -> service2 -> ... -> controller? Do you have specific error types for each layer, similarly to DTOs, or do you have a single set (sealed class) of errors per domain (e.g. API endpoint)?
@Alejandro Serrano.Mena
For example, if you say that
callTwoServices
may only throw A or B, but because of implementation details later it's only A or C, you would need to change every usage of that method, which is not ideal
Well, some people regard this as a feature 🙂 If you add an additional error type to a method you are explicitly forced to handle it in the callers, similarly to java checked exceptions. While I somehow understand this argument, I don't think the increased compile-time safety benefits balance the incredibly verbose code.
a
@David Kubecka my general guidance is to treat errors as part of the API, so I try to have separate public-facing error types, and then internal error types the user never sees, and there maybe sometimes there's more mix between layers
y
I generally use separate types and provide convenient conversions when applicable (which works nicely with
withError
). Basically treat it similar to how you'd treat data between layers
While I agree with Alejandro about staying lax in what errors you promise, I think in internal methods you can be specific as long as you use context receivers and some
sealed
super type for your errors. For instance, a
context(Raise<MyErrors.A>, Raise<MyErrors.B>) fun callTwoServices()
can be called in a way that handles A and B separately, or instead it'll likely be called with a
Raise<MyErrors>
, and hence changing the code later to raise B instead of C won't actually require you to change everything everywhere.
k
What I do is have a sealed class of my error type with sub types. The service layer can raise the base error type (or child type if applicable). I also attach an error cause onto each error type so that I can trace what error caused the underlaying error.
It’s been working relatively well thus far in my application
u
[Hi all, this is my first post here] I'm having the same problem with typed error hierarchies in my codebase, where I want to minimize dependencies between modules. Typed errors that are part of a sealed hierarchy are problematic at the point where I want to compose errors from two independent libraries; the solution would be an exhaustive mapping and redefinition of library error types in the calling module. I found this blog post from Matt Parsons [Haskell-Land] highlights some more issues with typed error hierarchies, especially that error-types become too wide, so that exhaustive when-clauses don't provide any benefit because of errors that in fact cannot happen. There does not seem to be a simple solution even in the Haskell type system. So I am wondering if the (in general less advanced) type system in Kotlin has an edge here via context receivers (I haven't used them yet)?
d
Thanks for the link! While I wouldn't pretend that I understand all the Haskell peculiarities mentioned there, I think the basic language feature used in all the solutions is what Kotlin so far doesn't have: the union type. If we had that we could simply write code like this:
Copy code
fun doStuff1(): Result<Int, Error1>

// union of errors from callers
fun doStuff2(): Result<Int, Error1 | Error2> {
  val x = doStuff1().bind()
  ...
  return if (test()) {
    Ok(1)
  } else {
    Err(Error2) 
  }
}

// we can selectively handle specific errors
fun doStuff3(): Result<Int, Error2 | Error3> {
  val x = doStuff2()
  handleError1(x)
  ...
  return if (test()) {
    Ok(1)
  } else {
    Err(Error3) 
  }
}
Unless I'm overlooking something this would be typesafe, easy to reason about and without boilerplate.
k
We do have a union type, sort-of. It’s just, different.
y
Using contexts and
Raise
is the way to go here. It allows composing your error handling without weird nesting orders within
Either
or similar. I forsee that such data types like
Either
will be very rarely used: they'll only be needed when you want to store an error or a successful result
d
@Kev Yeah, if you mean modeling the union type via sealed interfaces then that really doesn't pass the boilerplate check. Apart from having to create the "union" error classes explicitly you also have to provide mappers, etc.
The new
Raise
feature indeed looks promising but it's biggest disadvantage is the context receivers/parameters are still experimental. Which is still much better state than with union types 🙂
Also, I think that the context approach would quickly lose the benefits once you want to name the error types, e.g.
Copy code
Domain1Error = Error1 | Error2
Domain2Error = Error2 | Error3
If I'm not mistaken, with
Raise
you would have to create the sealed interface for each domain error type and we are back at the startline.
y
Naming is annoying yes. I think there's some discussion in the contexts KEEP about having "context typealiases" that stand for multiple contexts, so maybe you'd have
contextalias RaiseDomain1 = (Raise<Error1>, Raise<Error2>)
k
What if they want to go the route similiar to the one KEEP around contracts, where it followed some really alien
fun something() contract [ implies condition] : String