Result is an elegant solution to what can be a syntactically complex problem, it enables us to work with asynchronous code in a strongly typed and meaningful way.
To fully understand Result
, some knowledge of Generics is recommended
Proposed in SE-0235 and released with swift 5, Result
is an enum with two cases - success
and failure
- that requires two types as type parameters. The first type parameter is used in the success
case and the second passed into the failure
case, the only condition here is that the second must conform to the Error
protocol. Parameters of the provided generic types can be passed back through the success
and failure
cases as associated values, if you've not come across associated values in enums the Swift documentation has a dedicated section here, TL;DR you can think of it like passing a variable to a closure or function.
In our example we're going to setup the most common use case for Result
, asynchronous network calls. Open up a playground and add the following struct
struct APIService {
func callToServerSucceeds(urlString: String,
complete: @escaping (Result<String, Error>) -> Void) {
complete(.success("Welcome to myserver.com!"))
}
func callToServerFails(urlString: String,
complete: @escaping (Result<String, Error>) -> Void) {
let error = NSError(domain: "myserver.com",
code: 404,
userInfo: ["error": "Not found"])
complete(.failure(error))
}
}
This should all look fairly familiar if you've done any kind of network calls in Swift, the main difference here is that...and this is a big secret...we're not actually calling a server 🤫, instead we're just calling the passed in closure with some fake data to mimic an asynchronous network call.
you'll notice that our closure takes a single parameter - Result<String, Error>
, the String
type parameter will be used in conjunction with our success
case as seen in callToServerSucceeds
, and the Error
type parameter used with our failure
case in callToServerFails
. We then go on to define the respective parameters in each of these functions, callToServerSucceeds
passes .success("Welcome to myserver.com!")
into its completion
closure and callToServerFails
constructs a custom error and passes it into the failure
case.
To see these functions in action, add the following code to the end of APIService
func callServer() {
callToServerSucceeds(
urlString: "https://www.myserver.com/message/") { (result) in
self.handleResult(result)
}
callToServerFails(
urlString: "https://www.myserver.com/bad/url/") { (result) in
self.handleResult(result)
}
}
func handleResult(_ result: Result<String, Error>) {
switch result {
case .success(let message):
print(message)
case .failure(let error):
print(error.localizedDescription)
}
}
Here we defined a new function to house our calls to our fake API, and passed the result to a new handleResult
function which handles the two possible cases of our Result
type. finally add these two lines to the bottom of your playground.
let service = APIService()
service.callServer()
Here we just instantiate an instance of APIService
and call our function to trigger our fake network calls.
If you run this code you should see the success
case print our welcome message, and our failure
case print a description of our custom error. At a basic level, that's all Result
needs to do...
...but there's more.
Custom Error types
Result
gives us the ability to pass in any type conforming to the Error
protocol, this means we can define our own custom error types to make handling our network calls even more declarative.
Add the following enum
at the top of the playground
enum NetworkError: Error {
case badURL
case invalidPayload
case unreachable
}
Now we have an enum with three known cases, in reality there would likely be more, but for simplicity sake we'll keep it short.
Now in APIService
replace Result<String, Error>
in the function signature of callToServerFails
and callToServerSucceeds
with Result<String, NetworkError>
, and replace callToServerFails
with the following
func callToServerFails(urlString: String,
complete: @escaping (Result<String, NetworkError>) -> Void) {
complete(.failure(.unreachable))
}
Notice we've replaced our NSError
object with a case from our NetworkError
enum! Lastly, we're going to update handleResult
with
func handleResult(_ result: Result<String, NetworkError>) {
switch result {
case .success(let message):
print(message)
case .failure(.badURL):
print("bad url")
case .failure(.invalidPayload):
print("invalid payload")
case .failure(.unreachable):
print("unreachable")
}
}
If you run the code again you should see "unreachable" print out in the console to match our .unreachable
case, changing the case inside callToServerFails
should print the corresponding message inside handleResult
.
Wrap up
With the addition of Result
, swift has changed the way we handle network code, previously there were two common patterns: use two separate closures - success
and failure
- with respective values, or a single closure containing optionals for success and failure cases (complete(result: [String: Any]?, failure: Error?)
), meaning we have to nil check/optionally unwrap everythign - not ideal. While these approaches are still valid, Result
gives us a succinct, strongly typed and 'swiftier' method for handling our asynchronous code moving forward.
Thanks for reading! ✌️