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

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.

6 Likes

Also, I just want to state this because I've read the word "frustration" a couple of times and I just want to be clear here. I was not frustrated with Swifts error handling at all before writing this thread, so I definitely did not write the proposal out of frustration. Instead I was happy that I found a nice working solution based on Result and this proposal was meant to improve the ergonomics a bit.

Independent of how things go forward with this proposal or typed throws in Swift, I will most probably keep my Result-based solution in my apps because it fulfills my needs the most, and I will also probably write about it, explaining it all in detail to those who are interested. I really don't feel any hatred or frustration, I'm sorry if any of my posts sounded different.

The only annoying thing that frustrates me a little bit now after writing the pitch is the fact that my idea gets rejected by some based on historical discussions about typed throws. I see that as a separate topic though, simply because I'm just trying to improve an already existing feature that already can be used as an alternative to error handling using throws. I do not know the history of how and why the Result feature was introduced, I'm just trying to use the best tools I have available the best way I can for my needs and encountered an inconvenience that I tried to solve. I am a bit surprised that this seems to raise the tempers. I did not intend to do that. And this being my first pitch, it's a bit demoralizing, too. Things like "you're fighting the language, and suffer the bad consequences of this bad decision" when I'm just using built-in Swift features in a way that I actually think is pretty smart...

3 Likes

I apologize if my answers were demoralizing, and for my poor choice of words. Thank you for your detailed expression of your problems with untyped errors. I still think that this was a necessary step for this discussion.

3 Likes

I'm sympathetic to your goal of writing type-safe error handling code, with simpler syntax, but I'm not sure if the proposed syntax is a great choice. We already use if let and guard let to unwrap Optional values. Reusing the same syntax for enums other than Optional seems confusing to me.

if let value = maybeValue { ... }

… is already sugar for

if case .some(let value) = maybeValue { ... }

You propose it also becomes sugar for

if case .success(let value) = maybeValue { ... }

Apart from Optional and the various ExpressibleBy...Literal conforming types, I don't think we have much precedence for elevating certain types with their own special syntax. This feels like a piece-meal ad-hoc solution glued onto Result, with the added disadvantage of overloading existing syntax for added confusion. I'm not sure it results in clearer code.

Plus, there are other types where you want to guard on the "happy" path and make some kind of "unwrapping" in the sad path. Like when you guard error == nil else { } and then have to force-unwrap in the else-clause.

I think we either have to agree as a community that we want typed errors, and make typed throws, or create clear, unambiguous and scalable syntax that we can use as a tool in the language, applicable to any "either/or"-types, and usable across the board.

4 Likes

I’m open to a less confusing syntax if people feel if/guard would be confusing. Note though that “just” if/guard without a catch wouldn’t work like in your examples with my suggestions.

That said, I agree that there should be an as general as possible solution to mutually exclusive types. First I considered writing a proposal for any enum with exactly two cases. But then I read somewhere in the forums that an either type was already considered and dropped in favor of Result, basically representing the most useful case for an either type. So I figured making a smaller suggestion is more likely to get accepted. But I’m totally open to get back to that tuple/bi-enum idea. Do you have a suggestion how that could look like? And is it really more likely to be successful?

Would this work for you (if it was possible)?

guard let profile = try profileResult.get() else {
    return .failure(.apiError(error))
}
3 Likes

Yes, the extra “try” and “.get()” are short enough and wouldn’t bother me at all. The important thing for me is that the “error” needs to be precisely typed by the failure type of the Result.

Everybody needs that even though nobody needs typed errors. Like I showed above, without it, what we have gets horrible unless all error handling is performed at one final endpoint.

func sendTheErrorAlong(value: () throws -> Value) throws {
  let value = try value()
  value
func handleBeforeSending(value _value: () throws -> Value) throws {
  let value: Value

  do {
    value = try _value()
  } catch {
    Logger().error("\(error)")
    throw error
  }

  value

There's nothing in the standard library to help with it, e.g.

let value = try `do`(value) { Logger().error("\($0)") }
public func `do`<Success>(
  _ success: () throws -> Success,
  catch: (any Error) -> Void
) throws -> Success {
  do {
    return try success()
  } catch {
    `catch`(error)
    throw error
  }
}

I'm currently under the impression that a guard/catch statement is all that's necessary to fix the problem, and a do let wouldn't offer any more functionality.

Have you tried mapping the results? It seems to work best if the errors are mapped first.

func loadData() -> Result<Void, ScreenError> {
  return fetchProfile().mapError {
    ScreenError.profileError($0)
  }.flatMap { profile in
    self.name = profile.name
    return fetchImage(url: profile.imageUrl).mapError {
      ScreenError.imageError($0)
    }.map { image in
      self.profileImage = image
    }
  }
}

(Each fetch method uses a different error type, to see how that would work.)

You could also try using custom APIs with multiple trailing closures.

func loadData() -> Result<Void, ScreenError> {
  return fetchProfile().flatMap { profile in
    self.name = profile.name
    return fetchImage(url: profile.imageUrl).map { image in
      self.profileImage = image
    } mapError: {
      ScreenError.imageError($0)
    }
  } mapError: {
    ScreenError.profileError($0)
  }
}
extension Result {

  func map<NewSuccess, NewFailure: Error>(
    _ newSuccess: (Success) -> NewSuccess,
    mapError newFailure: (Failure) -> NewFailure
  ) -> Result<NewSuccess, NewFailure> {
    mapError(newFailure).map(newSuccess)
  }

  func flatMap<NewSuccess, NewFailure: Error>(
    _ newSuccess: (Success) -> Result<NewSuccess, NewFailure>,
    mapError newFailure: (Failure) -> NewFailure
  ) -> Result<NewSuccess, NewFailure> {
    mapError(newFailure).flatMap(newSuccess)
  }
}

I haven't used Result in a project, so I'm unsure of this approach for more complex examples.

I like the idea of guard .. catch, however I think it's not enough and the ideal solution would be for compiler to recognise try Result.get() as typed throw and automatically bind the error in catch statement to Failure type held by Result:

let result: Result<T, F> = ..

// Result.Failure is bound to `let error` either implicitly or 
// explicitly using variable declaration following 
// the catch statement.
guard let value = try result.get() catch [let error: F] {
   // ...
}

In cases when the statement is complex, compiler could fallback to generic Swift.Error, so in pseudo language:


let resultA: Result<T, F1> = ..
let resultB: Result<U, F2> = ..

<InferredFailure> 
guard let valueA = try resultA.get(), 
      let valueB = try resultB.get() 
catch [let error: InferredFailure] 
where 
        InferredFailure = F1 == F2 ? F1 : Swift.Error
{
   // ...
}

For any other throwing functions, compiler could continue using Swift.Error, i.e:

func fn() throws -> Value {
   throw MyError()
}

guard let value = try fn() catch [let error: Swift.Error] {
   // ...
}

Same concept applies when unpacking result and calling any other throwing function within the same guard .. catch statement, i.e:

func fn() throws -> Value {
   throw MyError()
}

let result: Result<T, F> = ..

guard let value = try fn(), 
      let otherValue = try result.get() 
catch [let error: Swift.Error] {
   // ...
}

The one could also explore the idea of making try statements act as early return in contexts returning Result<T, F>:

func a() -> Result<T, F1> {
  let value = try b().mapError({ .. }).get()

  // Do something with value ...
}

func b() -> Result<T, F2> {
  // ..
}

which compiler could translate into:

func a() -> Result<T, F1> {
  let value: Value
  do {
      value = try b().mapError({ .. }).get()
  } catch /* let error: F1 */ {
      // Early return
      return .failure(error)
  }

  // Do something with value ...
}

func b() -> Result<T, F2> {
  // ..
}

Edge case here a function that both throws and returns a Result<T, F> which makes little sense to me, hence it could be tackled in a few ways:

  1. Can be made illegal.

  2. Ignore return type and act as a regular throwing function. That should provide backward compatibility with existing code. Compiler perhaps could issue a warning to notify the developer that perhaps they should stick either with exceptions or Result<T, F>.

  3. Different keyword can be used such as unwrap instead of try to avoid collisions with exceptions, which could be made available only if the function returns Result<T, F> and act in similar fashion as early return ? operator in Rust. But that would be the first class support for Result which the core team seem to have some issues with for reasons that are unknown to me.

    func fn() -> Result<Int, FailureA> {
       let resultA: Result<Int, FailureA> = ..
       let resultB: Result<Int, FailureB> = ..
    
       // returns .failure(error) if resultA contains failure,
       // otherwise assigns valueA and continues execution.
       let valueA = unwrap resultA
    
       // returns .failure(error) if resultB contains failure,
       // otherwise assigns valueB and continues execution.
       let valueB = unwrap resultB.mapError { /* map to FailureA */ }
    
       // Executes when both valueA and valueB are available.
       return .success(valueA + valueB)
    }
    

    Compiler could generate all the boilerplate that we complain about here, such as match success, failure branches and do the implicit return. This approach also does not require re-engineering the guard statement as they become unnecessary but it would require the introduction of a new keyword specifically for Result<T, F> type. In my opinion this is a lesser evil than bending the existing exception mechanism to work for Result<T, F>.

Either of the suggestions above would enable writing concise and strictly typed, yet powerful code with nice early returns, while also providing ability to map returned errors before returning them. However someone with more experience should refine my proposal if either of this is ever going to be (even) considered to be implemented. I am obviously just throwing some ideas.

3 Likes

My problem is not mapping the success or failure types. By using mapError I just get a different kind of error type, but unwrapping it is still exactly the same as before. I actually don't have any problems with mapping the types, that's not what I'm trying to solve.

Also, I don't want to do all my work inside of flatMap closures. I find them hard to read and reason about. I prefer structured programming where I can read the code top-to-bottom as much as possible. This also makes it simple to have multiple failable calls in a row without deep nesting.

3 Likes

You are very precisely describing the challenge. I have had this though many times, although not this precisely and concisely put. I have tried to describe the problem on "Using Swift" but had no useful responses.

I really like the strongly typed and explicit nature of Result, just like you describe.

Your solution is really good in my opinion. I think the syntax you propose has really good ergonomics and improves code readability a lot.

I do not particular like the use of catch as keyword but I have a hard time suggesting anything better.

I do not like the auto-generated let error in the normal do-catch and I do not like it here. I would prefer to have the explicit form catch let error be the only one allowed.

Those are very minor objections and I would love to see this added to the language. It would improve the ergonomics of using Result a lot.

1 Like

I feel the exact same way! I agree 100 % with both you original post and the answers you have given to objections and very much with this description. You are putting your energi a place where I had given up because of the responses I have seen so many times her on the forums.

I really hope you keep up the effort. It is much appreciated! Very much.

2 Likes

I really like your explanation here. It clearly shows that there definitely are important scenarios where having a feature like you pitched here would be really nice.
However, as others pointed out already, your pitch seems to actually just be a part of the much larger feature typed throws that was already heavily discussed a while ago. Even the core team (or at least members thereof) showed some interest at some point. However, the discussion somehow has come to nothing and slowly died off. Maybe you could revive it by providing your excellent explanation there. Because your use-case would definitely be covered by the larger feature and this solution would fit the language much better, IMHO, since it doesn't introduce a little feature that goes against anything related in Swift, but rather really fixes the underlying error.

EDIT:
Just scrolled to the bottom of that thread and saw that you already engaged in the discussion there with a similar argument. Nevermind then

1 Like

I would be supportive of being able to have syntactic sugar to treat Result as Optional ( failure case with an empty error)

let myResult: Result<String, Error> = .success("Hello")

// sugar: guard let someResult = myResult else {
guard let someResult = try? myResult.get() else {
	 fatalError()
}

print(someResult + " World")

Then we can have if-catch-else, guard-catch-else where catch would only trigger if there is an error. I am just not sure how successful a proposal in this area is going to be. I use go nowadays and Swift’s error handling is so much better.