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
.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 forsuccessValue
anymore? 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 (profileResult
andimageResult
) because otherwise I would have to make a second call in theelse
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:
-
It doesn't allow for unwraps of the
Result
type without influencing control flow like with aif-let-catch
but always goes for the semantics ofguard-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. -
The keyword
do
is 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 theResult
types.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 catchederror
would be typed forResult
types 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.