If/guard-let-catch for conveniently accessing both Result type cases

Motivation

A few weeks ago, I had asked on Twitter if developers prefer error handling in Swift by using throws vs. returning Result. Of the 102 votes, 45% voted for "I prefer Result", which is 40% more than the 32% who voted for "I prefer throws" (source).

While this small survey isn't representative by any means, it still proves that there's clearly high interest in using Result for error handling (and not only for closure return types), even if that means we have to build nested error types. I personally applied this in my current project and ended up with 47 error types for a project with the size of 24k lines of code (without comments or blank lines):

I'm totally fine with writing these error types and having nested cases, I actually love it so far for its clarity. It even allows me to provide a global error handling solution for my entire app that's directly helpful to my users because I can provide a unique, nested error code which helps users find similar bug reports and me to find the exact place in the hierarchy where an error was thrown. All thanks to explicit errors types the Result type offers.

The only thing I really dislike is all the switch-case statements I have to write when calling APIs that return a Result, really bloating up my code base and making it less readable. For example:

enum ApiError: Error { /* ... */ }

func fetchProfile() -> Result<ProfileResponse, ApiError> { /* ... */ }
func fetchImage(url: URL) -> Result<Image, ApiError> { /* ... */ }

enum ScreenError: Error {
   case apiError(ApiError)
}

class Screen {
   var name: String?
   var profileImage: Image?

   func loadData() -> Result<Void, ScreenError> {
      switch fetchProfile() {
      case .success(let profile):
         self.name = profile.name

         switch fetchImage(url: profile.imageUrl) {
         case .success(let image):
            self.profileImage = image
            return .success(())

         case .failure(let error):
            return .failure(.apiError(error))
         }

      case .failure(let error):
         return .failure(.apiError(error))
      }
   }
}

To make this code shorter and more readable, I wrote myself an extension to the Result type:

extension Result {
   /// Convenience access to the `.success` parameter if the ``Result`` is a success, else `nil`. If this is `nil`, ``failureError`` is guaranteed to be not `nil`.
   public var successValue: Success? {
      switch self {
      case .success(let value):
         return value

      case .failure:
         return nil
      }
   }

   /// Convenience access to the `.failure` parameter if the ``Result`` is a failure, else `nil`. If this is `nil`, ``successValue`` is guaranteed to be not `nil`.
   public var failureError: Failure? {
      switch self {
      case .success:
         return nil

      case .failure(let error):
         return error
      }
   }
}

With that, the above loadData() function shrinks down from 13 nested lines to 11 un-nested lines:

func loadScreenData() -> Result<Void, ScreenError> {
   let profileResult = fetchProfile()
   guard let profile = profileResult.successValue else {
      return .failure(.apiError(profileResult.failureError!))  // <-- force-unwrapping
   }

   self.name = profile.name

   let imageResult = fetchImage(url: profile.imageUrl)
   guard let image = imageResult.successValue else {
      return .failure(.apiError(imageResult.failureError!))  // <-- force-unwrapping
   }

   self.profileImage = image

   return .success(())
}

While this improves the readability (in my opinion) and keeps the precise-error typing, it comes with its own drawbacks:

  1. I have to force-unwrap the .failureError which might seem fine because it's guaranteed to be non-nil if the .successValue is nil. But what if I change the condition later to not check for successValue anymore? Then the app crashes! The compiler can't help me to keep the code safe, so it's possible to introduce errors.
  2. I have to write an extra line to store the result of the fetchProfile() and fetchImage(url:) functions in a variable (profileResult and imageResult) because otherwise I would have to make a second call in the else case which could potentially have different results and do duplicate work.

Proposed Solution

I propose to give the Result enum the same treatment that the Optional enum has gotten with a syntactic sugar syntax that both shortens the unwrapping for better readability and increases safety by allowing the compiler to type-check the "exclusive or" nature of the Result type.

About the relationship between `Result` and `Optional` While not exactly true, in a certain way an `Optional<T>` can be seen as nothing else than a `Result<T, EmptyError>` where `EmptyError` is a theoretical type that contains no specific information about why the `Result` has no value, turning the `failure` case into a mere statement *that* the `Result` doesn't have a value – much like `Optional.none`. From that perspective, `Result` is simply an abstraction over `Optional` and `Optional` the specific case of a `Result` where the reason for not having a value is stripped/empty. Therefore, these types can be considered to be closely related.

For Optionals, Swift reduces something like the following code:

// func fetchData() -> Data?

switch fetchData() {
case .some(let data):
   self.data = data

case .none:
   self.handleNoData()
   break
}

To one of these:

if let data = fetchData() {
   self.data = data
} else {
   handleNoData()
}

// OR

guard let data = fetchData() else { 
   handleNoData() 
   return
}
self.data = data

Likewise, I'm suggesting we allow reducing something like the following:

// func fetchData() -> Result<Data, CustomError>

switch fetchData() {
case .success(let data):
   self.data = data

case .failure(let error):
   handleError(error)
   return
}

To one of these:

if let data = fetchData() {
   self.data = data
} catch {  // <-- unwraps the failure case into a precise-typed `error: CustomError`
   handleError(error)
}

// OR

guard let data = fetchData() catch {  // <-- unwraps the failure case into a precise-typed `error: CustomError`
   handleError(error) 
   return
}
self.data = data 

Detailed Design

New if-let-catch and guard-let-catch statements are introduced with exactly one if condition allowed and a required catch statement. The condition is restricted to only one if let or guard let statement with a right side of the = that returns a Result.

The compiler auto-generates a variable named error available inside the catch clause. A custom name can be provided similar to do-catch statements like so:

// let dataResult: Result<Data, CustomError>

if let data = dataResult {
   // ...
} catch let customError {
   print("Custom error occurred: \(customError)")
}

The implicit error or explicitly provided name (like customError) is precisely typed, e.g. CustomError in the above example, not just Error.

See the “Future Directions” section for multiple if-let statements or interoperability with if-else or guard-else statements. The design is explicitly designed to allow such additions.

Source Compatibility

This is a purely additive change and should not have an effect on compatibility. But I’m no compiler expert, so maybe I’m wrong.

Alternatives Considered

Naming of the catch

Instead of introducing a new if-catch and guard-catch syntax, we could just reuse the existing if-else where the else clause would have the error unwrapped, same for guard (this was suggested in this thread). The problem with this is that it doesn’t communicate to the developer reading this code that there are any Result types involved in the if or guard condition as it looks like plain calls. This is better communicated by putting a catch in place of the else, which is already known by developers to catch an error in do-catch statements.

This is also the advantage of catch over introducing an entirely new keyword like failure for the catch case, leading to if-failure and guard-failure. catch is already familiar and has very similar semantics.

do-let-catch syntax

Instead of if let x = someResult { } catch { } and guard let x = someResult { } catch { return } we could also just introduce do let x = someResult { } catch { return } similar to do { } catch { }. But this has 2 disadvantages compared to the proposed solution:

  1. It doesn't allow for unwraps of the Result type without influencing control flow like with a if-let-catch but always goes for the semantics of guard-let-catch where the body of the catch must leave the current context. This is a less flexible solution as developers might sometimes need that and sometimes not.

  2. The keyword do is currently used for throwing functions and therefore is clearly connected to Swift built-in and non-typed error handling using throws. Using the same keyword for a non-throwing function seems to be inconsistent and could lead to confusion. If anything, then the call should use the Result types .get() function like in do let x = try someResult.get() { } catch { } for consistency. But then developers might want to use that with any kind of throwing function and it would be weird if the catched error would be typed for Result types with a .get() function but untyped for any other kind of function that is just marked as throws.

Precise error typing

One might say that instead of improving the readability, ergonomics and safety of the Result type, we could also just introduce precise-typed errors like this which was discussed in this thread:

func fetchData() throws CustomError -> Data

I am not at all against this, quite the opposite, I’d love to see this added. But first, there currently seems to be no plan of actually adding this to Swift despite the topic being around since as early as the very first month Swift was released and being re-discussed at least here, here, here, here, and here. Second, even if it got added, it still doesn’t mean there are no use cases for the Result type anymore and that these would still benefit from the suggested improvements. Additionally, improving the Result ergonomics could actually make more people adopt Result-returning APIs whenever they need precise errors and allow to go back to throws where needed, giving developers full flexibility.

Future Directions

Allow multiple let definitions in one if or guard clause

Like with if-else and guard-else, multiple condition statements could be provided inside the condition using a , separator. This could be solved either by one catch let, let like here:

// func fetchProfile() -> Result<Profile, ApiError>
// func loadImage(url:) -> Result<Data, NetworkError>

if let profile = fetchProfile(), let imageData = loadImage(url: profile.imageUrl) {

} catch let profileFetchApiError, let dataFetchNetworkError {
   // ...
}

Or multiple catch clauses could be required for each one like so:

// func fetchProfile() -> Result<Profile, ApiError>
// func loadImage(url:) -> Result<Data, NetworkError>

if let profile = fetchProfile(), let imageData = loadImage(url: profile.imageUrl) {

} catch let profileFetchApiError {
   // ...
} catch let dataFetchNetworkError {
   // ...
}

Mix & match with if-else and guard-else

The current proposal makes users either use an if-else or if-catch statement based on the conditions return types. A future proposal could add a possibility to mix a catch into an existing if-else statement. This could work by requiring a catch to always be added after an if condition with a Result type inside, same for guard – else being last. This could allow code like:

// func fetchImage() -> Result<Image, ApiError>

if let num = Int("50"), let image = fetchImage() {
  // ...
} catch {
   print("Fetching image failed with error: \(error)")
} else {
   // ...
}

What do you think?

Please note that this is the first pitch I wrote, also I am by no means a compiler expert, so I have no idea how complex it would be to implement this. I just hope it's doable.

9 Likes

It sounds like you want to be able to use catch to handle errors with an API that returns Result. You don't need to switch over the success and failure cases to do that today. Instead, you can accomplish what you describe using get():

do {
  self.data = try fetchData().get()
} catch { // or, if you want to only handle one error type, `catch let error as CustomError`
  handleError(error)
}

You can see how there is no need to store the result of fetchData explicitly or perform any force unwrapping.

6 Likes

The counter example will not compile. The compiler will object that Error cannot be converted to CustomError and suggest using as!.

The comment suggestion also will not compile, since the compiler will object that thrown errors are left unhandled (due to try erasing the type information and the catch therefore not being exhaustive).

3 Likes

The counterexample compiles just fine assuming handleError handles any error, and the alternative compiles just fine assuming an exhaustive catch is used afterwards. This is a snippet illustrating use of get(); users can tailor to their own needs.

I appreciate the effort very much but what you're offering is an Einstellung solution. The underlying problem is that do-catch does not have a guard/if-equivalent binding syntax—instead, it requires non-shadowed parameter names and explicitly-typed declared-but-not-assigned constants. Ugly stuff.

func `do`<T>(_ _t: () throws -> T) {
  let t: T
  do {
    t = try _t()
  } catch {
    error
    return
  }

  t
}

func `guard`<T>(_ t: () throws -> T) {
  guard let t = try? t() else {
    // No `error` here. 😿
    return
  }

  t
}

do/let or an equivalent would fix that:

func `doLet`<T>(_ result: Result<T, some Error>) {
  do let t = try result.get() catch {
    error
    return
  }

  t
}

No, the error is not strongly-typed, but I'm not onboard with that being an attackable part of the problem, given

That said, maybe a special case can be made for do let success = result, emulating this overload. I don't see how it could cascade to multiple Results as you were suggesting above, though. It seems to me that a separate do-catch for every Result is the only way to avoid positional confusion.

public func `do`<Success>(
  _ success: () throws -> Success,
  catch: (any Error) -> Void
) -> Success? {
  do {
    return try success()
  } catch {
    `catch`(error)
    return nil
  }
}

public func `do`<Success, Failure>(
  _ result: Result<Success, Failure>,
  catch: (Failure) -> Void
) -> Success? {
  switch result {
  case .success(let success):
    return success
  case .failure(let failure):
    `catch`(failure)
    return nil
  }
}
struct Error: Swift.Error {
  let property = "😫"
}

do {
  let result: Result<_, Error> = .success(true)

  guard let success = `do`(
    result.get,
    catch: { _ in XCTFail() }
  ) else { return }

  XCTAssertTrue(success)
}

do {
  let result: Result<Void, _> = .failure(Error())

  guard let _ = `do`(
    result,
    catch: { XCTAssert($0.property == "😫") }
  ) else { return }

  XCTFail()
}
1 Like

The whole point of my proposal is that I want to have precise typed errors like I explained in the motivation part. I am aware of the throwing ‘.get()’ function, but that loses the preciseness of the error type, so that’s not a viable solution for me unless Swift gets typed errors which it currently doesn’t look like.

2 Likes

Note: I just added a do-let-catch syntax to the "Alternatives considered" section with my rationale why I think it's not a good idea to use that kind of syntax. Also, I have added links to older threads about typed throws to emphasize that the discussions is around for very long without any concrete plans of integrating such a feature into the language.

The fact that a significant population of language users want typed errors, but the core team has historically been reluctant to add them, is well-established. I understand that this can be frustrating if you’re in that population, but I cannot imagine that having different special syntax for typed and untyped errors is going to be a winning solution.

11 Likes

When you invoke get() and then catch the thrown error, the underlying type of the error isn't lost. Swift deliberately steers you towards handling more than the one type of error, but as discussed above, you can choose to handle a specific error in each catch clause and assert that the alternatives are unreachable (analogous to @unknown default for non-frozen enums).

If, at base, you disagree with Swift's opinion and want to change the language to support typed throws such that you don't have to consider the possibility of other errors—indeed, if as you say it's the "whole point," then the proposed solution which would address that would be typed throws. My point is that the remainder of issues you bring up about having to bind the result or to use force unwrapping aren't the case, and avoiding these issues is possible even today and doesn't require any new syntax to do so.

7 Likes

When you invoke get() and then catch the thrown error, the underlying type of the error isn't lost.

When I just catch, then I get an error: Error which I can't make any calls of my custom error type on. Regarding developer ergonomics, this is very much like a type-erased value as I have to unwrap the type myself, which I don't want to do.

Swift deliberately steers you towards handling more than the one type of error

But what if I have exactly one type of error in all my own functions each? That's what I'm using Result for and I'm not alone. I just don't want to use throws because it lacks the amazing compiler safety ensuring I cover all error cases that Swift provides in all other places.

If, at base, you disagree with Swift's opinion and want to change the language to support typed throws (...) then the proposed solution which would address that would be typed throws.

I already commented on that, see the "Alternatives Considered" section.

My point is that the remainder of issues you bring up about having to bind the result or to use force unwrapping aren't the case, and avoiding these issues is possible even today and doesn't require any new syntax to do so.

I'm sorry but how aren't they the case? I explained in detail how they are. Can you give me an example where both the compiler ensures I handle all error cases and I don't have to write unnecessarily verbose code? I'm not aware of any way to do error handling today in Swift that is both safe and convenient to work with in application level programming.

1 Like

I don't think the compiler can ever be sure all possible errors are handled. At best, it could check that you handle all possible types.

Why not? Isn't this a nice solution to conciliate both sides by giving each what they want? I mean, the Result type already has a .get() function that can always convert any API that returns a Result into a throwing function, so there wouldn't even be "two worlds of error handling", there still is only one way of automatic error handling in Swift. But the Result type just gets some more powers so it becomes more useful. I don't understand what's so bad about it...

1 Like

I don't think the compiler can ever be sure all possible errors are handled. At best, it could check that you handle all possible types .

If all your failable functions return a Result instead of throwing (like in my current app), then the compiler checking that I handle all possible types effectively means that the compiler is checking that I handle all possible errors. That's my approach to error handling. But it's currently a bit inconvenient.

1 Like

No. The argument against typed throws has not been based in syntax, which is relatively trivial, but rather the belief that having a closed set of errors is a bad idea more often than not. Having parallel syntax options doesn’t address that concern.

Having generic Failure in Result but no typed throws is an unfortunate impedance mismatch, and the resulting split between people who use Result for the typing and those that use the language as intended was a predictable outcome (and predicted at the time). Doing it anyway was justified on the basis that an untyped error in Result would block the possible future addition of typed throws.

If the community and the Language Workgroup conclude that actually, developers can be trusted with typed errors, the natural result would be to converge by adding typed throws, rather than further diverge by adding more sugar to Result, a type that was intended to be largely marginalized by async/await.

2 Likes

How is anyone supposed to know better than a specific projects developers if having a closed set of errors is a bad idea for their specific use case or not? This is the part I'm struggling with. I get that Swift is opinionated on some things to ensure its documented goals of being safe, fast and expressive are met. I just don't see how typed errors or the less invasive proposal I make here go against any of their goals, quite the opposite.

And I still disagree that my proposal would create 2 worlds of error handling in Swift. To "use the language as intended" you can simply call .get() on the Result type. And @Jon_Shier addressed the concerns for a possible future implementation of typed throws in the related thread pretty well:

2 Likes

Litigating typed errors is probably not worthwhile if there is no new information to offer.

I also think that asking for syntax to make Result more ergonomic is a non-starter. The Core Team was very reluctant to add the type in the first place, and no Apple code uses it, AFAIK.

@Jeehut, you do understand that just claiming that you need better Result ergonomics isn't enough: many people here just think that you're fighting the language, and suffer the bad consequences of this bad decision.

To sum up, you are encouraged to answer Precise error typing in Swift. Maybe refactor your Result-based code with regular throwing methods, and evaluate the eventual downsides? This could help strengthen your answer to the language direction that has been clearly expressed, or make you discover that your use case is already covered by the proposed future directions. Maybe you will make peace with untyped errors. Who knows?

I actually went the reversed route: I had all APIs use throws first, but I couldn't handle them all in a safe way. I can assure you that I thought a lot about it and tried different things. In the end, I refactored my entire project to use Result instead (which took 2 full work days) and this solved all my problems except for the two mentioned in the proposals motivation.

The Core Team has proven with SE-0345 that it can change its opinion, even if a requested feature is on the list of Commonly Rejected Changes. Most interestingly, typed throws aren't even on that list.

2 Likes

So you had "problems" with "safety", all right.

which took 2 full work days

I understand your frustration. Now what you are asking for will clearly need more than 2 full work days, I'm sure you understand this. The thread is two-days old as well, and several people have tried to give meaningful answers in these two days. Maybe it will take less time for you to describe what exactly are those "problems" with "safety"?

I'll happily explain my requirements in more detail to show my "safety problems" with throws:

  1. I accept that throwing functions in system libraries or 3rd party libraries I use in my app like String.write(to:atomically:encoding:) could throw any kind of error (although it would be really nice if common ones would be easier to explore so I can choose if I want to handle some). I could never potentially handle them all because I have no control over them and their release cycle is independent from my apps release cycle, so error cases could change anytime making it inconvenient to update my code. So if I call into these, I might catch a few cases I encounter during development (or through users reporting them) and handle them with my custom logic (e.g. presenting instructions to users how they can fix the issue they've encountered). The current throws with catching the types I explicitly want to cover works fine here. Just documentation needs more improvement, I guess.

  2. I do not want to miss handling any of the errors I throw myself in my application, because I want to force myself to provide some useful custom logic to handle every error produced by my own code to make the user experience for my users as smooth as possible. This is really important to me, so I use enum types in different parts of my application and add a new error case for every new assumption I have in code that is not being met by the data I find (mostly caller input data). By returning a Result with my custom error enum I make sure the call side can easily switch over all possible error reasons and act accordingly (either by providing an automatic resolution or presenting the error with clear instructions and error highlighting to my users).

  3. When users come across any of my apps errors that could not be automatically resolved but are just presented with instructions to the users, I want my users to be able to report them to me as questions (so I can help them understand it better & maybe improve the instructions in the app) or as unexpected errors (so I can fix issues in my code when not the user did something weird but my app just had a bug). In any of these cases, in order to be able to investigate the issue, I don't just want to know the specific module the error was encountered, but I also want to know the entire "error call stack" so I know which screen the user was in, which other module this was calling, with other module that module was calling etc. until the error is encountered. To do that, I nest my error types, so an error type of a screen of my app can have 2 local error cases and 5 nested error cases. I define a custom protocol that all my error types need to adhere to so I can build that "error call stack" by concatenating the nested error cases unique identifier to the local error cases unique identifier, prepended by the entry error types unique type identifier, leading to an error code like "CFG-GPF" where "CFG" stands for the error type (so I know which top level screen/module the user was presented with the error) and the "GPF" is a list of nested error cases, e.g. "G: generateEnumFailed(StringsEnumError) => P: parsingError(ParsingError) => F: failed(expectedInput: String, remainingInput: Substring)".

Here's a typical error type in my app:

// `public` because my app is modularized using SwiftPM
public enum ConfigFileGeneratedCodeError: Error {
   // MARK: - Leaf
   case renameResourcesEnumFileFailed(errorDescription: String)
   case cantOpenNonExistentEnumFile(expectedFileUrl: URL)

   // MARK: - Nested
   case writeTempFileFailed(error: ReadWriteFileError)
   case appSandboxAccessError(error: AppSandboxAccessError)
   case generateEnumFailed(error: StringsEnumError)
}

If I used throws instead of Result in all my APIs, on the throwing end I would need to:

  1. Ensure I don't miss documenting my error type for each function.
  2. Ensure I don't miss wrapping any 3rd-party errors into my custom error types.

With throws instead of Result, on the call site I would need to:

  1. Look up which error type is documented for each throwing function I call.
  2. Provide a catch-all for errors I forgot to wrap into my custom error type (see #2 above).

The throws code would then look like this (note that I'm returning an Effect in TCA):

// func start() -> throws
let baseDirUrl: URL
do {
   baseDirUrl = try baseDirAccess.start()
} catch let unwrappedError as AppSandboxAccessError {
   return .init(value: .errorOccurred(error: .appSandboxAccessError(error: unwrappedError)))
} catch {
   return .init(value: .errorOccurred(error: .unexpectedError(error: error)))
}

Currently, by using Result I don't have all the error-prone burden from above on the developer and my code looks like this instead:

// func start() -> Result<Void, AppSandboxAccessError>
let baseDirUrlResult = baseDirAccess.start()
guard let baseDirUrl = baseDirUrlResult.successValue else {
   return .init(value: .errorOccurred(error: .appSandboxAccessError(baseDirUrlResult.failureError!)))
}

With my proposal, it would look like this and fix also the extra baseDirUrlResult variable and the force-unwrapping (!):

// func start() -> Result<Void, AppSandboxAccessError>
guard let baseDirUrl = baseDirAccess.start() catch {
   return .init(value: .errorOccurred(error: .appSandboxAccessError(error)))
}

I hope that was somewhat clear. I'm open to elaborate more if you have more detailed questions.

5 Likes