Improve Result and exception handling ergonomics

I have been scanning this forum for a while now without any luck to find a solution. I am heavy into Result type. Comes with years of Rust.

I can't stand untyped try, it's really comical if you look at any code using do-catch:

func myFunction() {
    do {
        try somethingRisky()
    } catch SomeError {
        // handle SomeError
    } catch {
        // should never happen but if it does, make sure to implode so that the code can compile
        fatalError()
    }
}

Because if you don't handle that Pokemon catch-them-all case, the compiler will sure add one for you if you have a function that throws, little surprise and automagic that should not have been there. One of things that really bothers me.

I myself sometimes resort to wrapping a try call into Result and then asserting the Error type, it sure was designed for that:

let result = Result({ try somethingRisky() }).mapError({ error in
    // I kid you not, it can only be that one error
    return error as! MyError 
})

Been waiting for some sort typed throws ErrorType for years but not seeing it anywhere on the horizon, although I enjoy the try syntax to certain extent, it has some weird quirks too, i.e:

let data: Data
do {
   data = try JSONEncoder().encode(...)
} catch {
   print("Got that pesky \(error)")
   return 
} 

// Do something with data...

It would have been so much more ergonomic to have a normal guard-let construct that could pass errors to the else branch, i.e:

guard let data = try JSONEncoder().encode(...) else {
    // The error is missing though!
    print("Got that pesky \(error)")
    return
}

// Do something with data...

It's the same with Result, it's impossible to guard case it the same way, i.e:

guard case .success(let value) = callFunctionReturningResult() else {
   // Since we can't obtain failure this syntax is basically cannot be used
}

Unless of course you do something like that:

let result =  callFunctionReturningResult()
guard case .success(let value) = result else {
   return result
}

Sure the one could switch-case into infinity but it creates unreadable code where usually the success branch is the one that has all the meat.

Result handling overall could have been seriously improved by supporting early returns Rust-style, i.e:

func myFunction() -> Result<T, F> {
    // trailing ? as an early return
    let data: Data = callFunctionReturningResult().mapError { ... }?

    return .success(1337)
}

Compiler could expand that to something like:

func myFunction() -> Result<T, F> {
    let data: Data
    let result = callFunctionReturningResult().mapError { ... }
    switch result {
      case .success(let value):
          data = value
    
      case .failure:
          return result
    }

    return .success(1337)
}

But implications are such that ergonomics and number of lines needed to handle Result are reduced to one. Surprises me that after all that time Swift has not improved a single bit on error handling and reducing amount of noise in code. Maybe it's just me feeling all that frustration punching sheets of boilerplate code on my keyboard, that should have been auto-generated for me?

3 Likes

There's very little interest in improving the ergonomics of Result specifically, especially Result-specific affordances in the language. However, Result will pick up support for an enum enhancements that come along, like generated discriminators or associated value accessors, or typed throws. Additional API surface for Result might be considered, I haven't asked. Previous pitches and PRs haven't gone anywhere.

In any case, I'm still not sure what your example is even doing. Why are the last two examples extracting some Data, doing nothing with it, and then returning some random success value? It seems like you could just express it as callFunctionReturningResult().map { _ in 1337 }.mapError { ... }.

Ultimately I suggest you adapt to how Swift prefers to do error handling.

1 Like
guard let data = try JSONEncoder().encode(...) else {
    // The error is missing though!
    print("Got that pesky \(error)")
    return
}

// Do something with data...

This would have been very nice, it's ergonomic and clear.

4 Likes

This:

is cool and concise, I like that.

This one:

indeed, would be handy, bike shedding:

guard case .success(let value) = callFunctionReturningResult() else {
    print(failure) // as if "case .failure(let failure) = ..." was done above
    return
}

This:

I don't like (maybe because I don't know Rust).

guard let data = try JSONEncoder().encode(...) else {
    // The error is missing though!
    print("Got that pesky \(error)")
    return
}

I also think this is convenient and intuitive; similar to how an undeclared error variable is bound in a catch { … } clause. Of course, its not flexible as catch let as in discriminating between errors, but switching on the error in the else clause provides the same ability, or perhaps simply refactoring to do catch if you need that.

I think this improves the usefulness of guard let and throwing functions in general. It becomes much easier to upgrade a function from returning nil to throwing errors and add basic error handling / logging at the call sites without having to rewrite every site as a do catch.

+1 for this syntax!

Given there's no visibility to the magic behavior here it's unlikely it would be adopted as proposed. Instead, it seems more likely we'd get guard - catch, as discussed previously, to handle both the Result.get and normal error throwing cases. Combined with typed throws, it would solve this problem universally.

We have to remember, Result was not accepted as an alternative to throws. Instead, it's more of a bridging type, especially between async and sync contexts that need to produce an error. Without typed throws it also serves as a typed Error container, but both of those needs could be addressed by the language itself. At that point it's simply a storage type, keeping a result for access later.

2 Likes

John McCall referred to this as “precise error typing” in September 2021.

I have no idea if there's been any compiler work on it, but it was (afaik) the first time McCall endorsed the idea.

1 Like

The example could have been better. I sure can map and flatMap but it does not scale well in complex scenarios. Nested map and flatMap with branching is a nightmare, that's why ergonomics around unpacking the Result are necessary. The same way guard keyword helps to flatten the code in other scenarios.

If Swift had a proper support for macro, I'd write a macro that would do that for me, i.e in pseudo language:

my_inline_pseudo_macro bail!(expr: ResultExpression) {
  switch expr {
      case .success(let value):
          value
      case .failure(let error):
         return .failure(error)
  }
}

func test() -> Result<MyValueType, MyError> {
    let data: Data = bail! getMyResultContainingData().mapError { MyError.failure($0) }

    // Do something with data...

    return .success(..)
}

But sadly Swift does not offer anything like that.

Not good enough.

You don’t have to do anything you don’t want to do. However, Swift is explicitly an opinionated language and also one that avoids having dialects, and it is by design that the ergonomics of the language steer you towards certain ways of error handling and not others. That Result does not have the same language support as Optional or try was not an oversight or limitation in implementation bandwidth but rather deliberate in design. The core team has rejected Result-specific affordances but, as @Jon_Shier notes above, have encouraged exploration of improvements that are broadened to enum handling in general.

1 Like

@pronebird I just wrote a pitch very much related to this thread, please see here:

1 Like