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:
- I have to force-unwrap the
.failureErrorwhich might seem fine because it's guaranteed to be non-nil if the.successValueis nil. But what if I change the condition later to not check forsuccessValueanymore? Then the app crashes! The compiler can't help me to keep the code safe, so it's possible to introduce errors. - I have to write an extra line to store the result of the
fetchProfile()andfetchImage(url:)functions in a variable (profileResultandimageResult) because otherwise I would have to make a second call in theelsecase 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:
-
It doesn't allow for unwraps of the
Resulttype without influencing control flow like with aif-let-catchbut always goes for the semantics ofguard-let-catchwhere 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. -
The keyword
dois currently used for throwing functions and therefore is clearly connected to Swift built-in and non-typed error handling usingthrows. 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 theResulttypes.get()function like indo 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 catchederrorwould be typed forResulttypes with a.get()function but untyped for any other kind of function that is just marked asthrows.
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.
