Aggregating errors and pattern matching

I'm using a home made promise implementation with strongly typed errors throughout my code base, but my question is equally valid for Combine's publishers and just old plain Swift.Result.Failure:

It is often the case that functions/publishers have a single well-defined error type, and that when combining two of more errors, one have to either erase the error types or create domain-specific combined errors such as:

enum NetworkError: Error { 
    case urlError(URLError.Code)
    case httpError(status: Int)
    ...
}

enum APIError: Error {
    case networkError(NetworkError)
    case parsingError(DecodingError)
    ...
}

It allows me to ergonomically switch over cases such as:

APIClient.currentSession.fetchData().onCompletion { result in 
   switch result {
   case .failure(networkError(.urlError(.timedOut))): // slow internet
   case .failure(networkError(.urlError(.noConnection))): // no internet
   case .failure(networkError): // other network errors.
   case .failure(.httpError(status: .unauthorized)): // not logged in
   case .failure(.httpError(status: 500..<599)): // server error
   case .failure(.httpError(status: 400..<499)): // client error
   case .failure(.parsingError): // invalid response
   case .failure: // show generic error message
   case .success(let response): // success!
   }
}

However, if the above is part of yet another function call that may have other errors, the number of error types quickly adds up. I've been thinking about creating a generic Either type that wraps two types into a .left and .right cases, and making it conform to Error when both its associated types also conform:

public enum Either<Left, Right> {
    case left(Left)
    case right(Right)
}

APIClient.currentSession.fetchModel() // ← Promise<Model, Either<NetworkError, DecodingError>>
    .flatMap(validateResponse)        // ← Promise<Model, Either<Either<NetworkError, DecodingError>, ValidationError>
    .map { $0.count }                 // ← Promise<Int, Either<Either<NetworkError, DecodingError>, ValidationError>
    .onCompletion { result in 
        // is is possible to avoid switching over
        // case .failure(.right(.left(.left(.networkError(.timedOut)))):
        // and instead switch like this:
        // case .failure(.networkError(.timedOut)):
        // etc...?
    } 

I've tried this:

extension Either: Error where Left: Error, Right: Error {
    public static func ~=(error: Self, pattern: Left) -> Bool {
        if case .left(pattern) = error { return true }
        return false
    }
    public static func ~=(error: Self, pattern: Right) -> Bool {
        if case .right(pattern) = error { return true }
        return false
    }
}

But that doesn't work, since Referencing operator function '~=' on '_ErrorCodeProtocol' requires that 'Left' conform to '_ErrorCodeProtocol'.

Also, even if I could get the pattern matching to work, I would still probably not get much help from the exclusivity checker, and it would probably take a toll on the type checker, too.

Any suggestions on how to get ergonomic aggregation of strong error types in a generic context? Is there a better way of dealing with this? I don't care if the actual error type is super contrived with many layers of wrapped generic types (like SwiftUI types), as long as the ergonomic of using them is not too bad. But perhaps there are better approaches to this?

1 Like

If you are ok with having everything get lifted to a single type, you could do something like the following: (names are placeholders, I don't feel strongly about them)

protocol Liftable {
    associatedtype Into
    func lift() -> Into
}

enum Error1 {
    case e1c1
    case e1c2
}

enum Error2 {
    case e2c1
    case e2c2
}

enum Error3 {
    case e3c1
    case e3c2
}

enum Error123 {
    case e123_e1c1
    case e123_e1c2
    case e123_e2c1
    case e123_e2c2
    case e123_e3c1
    case e123_e3c2
}

extension Error1 : Liftable {
    typealias Into = Error123
    func lift() -> Error123 {
        switch self { case .e1c1: return .e123_e1c1; case .e1c2: return .e123_e1c2; } 
    }
}

extension Error2 : Liftable {
    typealias Into = Error123
    func lift() -> Error123 {
        switch self { case .e2c1: return .e123_e2c1; case .e2c2: return .e123_e2c2; } 
    }
}

extension Error3 : Liftable {
    typealias Into = Error123
    func lift() -> Error123 {
        switch self { case .e3c1: return .e123_e3c1; case .e3c2: return .e123_e3c2; } 
    }
}

enum Either<L, R> {
    case left(L)
    case right(R)

    func liftEither<K>() -> K where L : Liftable, L.Into == K, R : Liftable, R.Into == K {
        switch self {
            case .left(let l): return l.lift()
            case .right(let r): return r.lift()
        }
    }
}

extension Either : Liftable where L : Liftable, R : Liftable, L.Into == R.Into {
    typealias Into = L.Into
    func lift() -> Into {
        return self.liftEither()
    }
}

func f(e: Either<Either<Error1, Error2>, Error3>) -> Error123 {
    return e.lift()
}

lift() will take any nested combination of Eithers and "flatten" it out to Error123. Hence, before pattern matching, one can call lift on the error part of the promise. I'm not sure if this is good enough. I suspect you might be able to create a more generic version with more specialized types for every possible combination, but that explodes like O(2^N) in the number of types, so that's probably undesirable.

I'm not sure why you can't just stay within the Error type here. What does explicitly brining along every error type get you? I usually just use Error until I need to extract the exact type (which is rare), in which case I write convenience casting properties on Error.

extension Error {
    var asAFError: AFError? {
        self as? AFError
    }
}

I guess for the same reason I don't want to use Any? everywhere. Strong type safety.
I like that the compiler yells at me for not dealing with an error.

If, at the call site, the details of the error isn't important, I could always just switch over .failure(_) or .success(let value) and not perform pattern matching on the associated error type.

I guess the issue isn't isolated to propagating errors, but the fact that I can easily use tuples for ad-hoc aggregation of product types, but have no built-in mechanism for ad-hoc aggregation of sum types. Especially in a generic context, I could always zip two types into a (A, B) type, but I can't easily do the same for either A or B. I'm trying to solve it using an Either enum, similar to what functional languages often do (where Result and Optional often will implemented in terms of the either type).

But there isn't really ergonomic ways of dealing with them, when they accumulate into deeply nested structures. (The same is somewhat true for nested tuples, nested optionals, etc)

1 Like

My point was that Error rarely requires such type safety, hence saving your type specific needs until the very end works fine. Your solution here will straightforward, if tedious: build out union types up to the arity you need.

enum OneOfThree<T, U, V> {
    case one(T)
    case two(T, U)
    case three(T, U, V)

    var first: T? { ... }
    var second: U? { ... }
    var third: V? { ... }
}

This trades your runtime complexity of nested types with development and build time issues, since you can't dynamically generate the types you need (unless you want to use Sourcery). Shouldn't be too hard, just tedious. Perhaps Swift will eventually gain easier support for stuff like that, but probably not soon.