Type safe networking with URLSession, Result, and Decodable.
We all know it's best practice to separate our code into individual components, each dedicated to a single task. This sounds simple enough, but rarely do we get it right the first time. Or even the fourth time.
I find that within my projects, it can take me several attempts — or even several projects — before I settle on an architecture that works so well that I feel the need to shout "I'm a genius!" to everyone around me in the open office.
They hate that by the way.
I've had a new method floating around in my head lately and have been testing it out at my current workplace. It's not groundbreaking by any means, but it has made performing network calls so much simpler, faster, and intuitive. It also hides away all that messy URLSession implementation people tend to duplicate in every one of their view controllers.
Current State of Affairs
Most projects I've worked on usually handle their networking code in one of the three following ways.
- MVC pattern, but URLSession is implemented within the View Controller.
- MVC pattern, but URLSession is wrapped up in it's own http service type class. This handles all config, request creation, caching rules and cookies. This is then called from the View Controller.
- MVVM pattern, but URLSession is implemented within the View Model.
I preferred the second option for a long time, which is what most other developers I know had also settled into. It's pretty much a standard.
With these approaches you need to parse JSON, check for errors, check the response codes, and usually do this in every place that your app is making requests. Of course, you could have a wrapper that does this for you. A wrapper will take care of those error cases, but they still usually only pass back JSON. Which then gets pushed to another layer — or multiple layers — that parse the JSON into usable objects, or even worse, the JSON is handled directly by the view controller.
There's a much more elegant solution using Decodable
, and Result
, that eliminates a lot of boilerplate from your network requests. Let's get into it.
Extending URLSession
Rather than creating a whole new wrapper around URLSession
, I find it's much better to write an extension. This gives us access to the new functionality anywhere in our project without having to come up with new class names. It also has the added benefit of forcing us to remain fairly neutral and "Apple" like when writing new APIs. Future devs and future you will be glad you took the extra time.
extension URLSession {
func perform<T: Decodable>(_ request: URLRequest, decode decodable: T.Type, result: @escaping (Result<T, Error>) -> Void) {
URLSession.shared.dataTask(with: request) { (data, response, error) in
// handle error scenarios... `result(.failure(error))`
// handle bad response... `result(.failure(responseError))`
// handle no data... `result(.failure(dataError))`
do {
let object = try JSONDecoder().decode(decodable, from: data)
result(.success(object))
} catch {
result(.failure(error))
}
}.resume()
}
}
What we've done here is wrap our usual URLSession.shared
code in a static function, hiding away all the handling of the data, response, and errors.
The perform
function accepts a request
, and a decodable
object — that is, an object that conforms to the Decodable
protocol. We then take that decodable object and try to decode the json returned from the request. If successful, an initialized object is returned in the closure.
For this example we're using Swift Error type, but I highly reccomend creating your own enum error type with cases covering the kinds of errors you're most likley to run into e.g. bad status codes, invalid URLs, timeouts, json decoder problems, etc.
The call site would then look something like this:
let url = URL(string: "https://example.com")!
let request = URLRequest(url: url)
URLSession.perform(request, decode: Object.self) { (result) in
switch result {
case .failure(let error):
print(error)
case .success(let object):
print(object)
}
}
Once the request has completed, we get a Result
back that can have only one or two outcomes. Success or Failure. There's no unwrapping or JSON parsing to do, our object is either available, or there was an error. So simple.
In most projects I've worked on, what's returned is usually an array or dictionary and the view controller or view model is responsible for pulling out the information it needs to display. This eliminates that entire step.
Requestable Protocol
That's great, but we're still creating a new request each time this function is called. In cases where we need to call the same endpoint in different areas of the app, having duplicated requests like this means that anytime we need to update one, we have to update them all. This can introduce bugs during refactoring, and it's also just more code that we have to write and maintain.
The best place I can think of is to put the request on the object we're requesting. It already knows how to encode/decode itself, so it's not that much of a stretch for it to understand how to also fetch itself.
Obviously we can't just add a new variable to Object
. Our perform
function only knows how to handle Decodable
items that are passed in. If we then decided to switch it to accept a type of Object
, we'd then be locking out all other types from making use of the perform
function.
Instead, we'll create a new protocol that conforms to Decodable
and adds a urlRequest
property that our perform
function can use.
protocol Requestable: Decodable {
static var urlRequest: URLRequest { get }
}
We have a static variable that is get only.
struct Object: Requestable {
static var urlRequest: URLRequest {
let url = URL(string: "https://example.com")!
let request = URLRequest(url: url)
return request
}
}
We then make our Object
type conform to Requestable
.
There may be times when you'll need more flexibility here, like passing in a http method such as GET, DELETE or POST where they're hitting the same endpoint. There's a few ways to handle this, like changing this to a function, and passing in your info that way. Instead I would suggest having twoperform
functions in yourURLSession
extension. One that takesRequestable
as a parameter (like you'll see soon), and one that takes aDecodable
andURLRequest
(like the one shown above). You can always add newpostRequest
ordeleteRequest
variables to your objects and just pass them through to the request param i.e.perform(request: object.deleteRequest, decode: Object.self)
.
extension URLSession {
static func performRequest<T: Requestable>(on decodable: T.Type, result: @escaping (Result<T, Error>) -> Void) {
URLSession.shared.dataTask(with: decodable.urlRequest) { (data, response, error) in
// handle error scenarios... `result(.failure(error))`
// handle bad response... `result(.failure(responseError))`
// handle no data... `result(.failure(dataError))`
guard let data = data else { return }
do {
let object = try JSONDecoder().decode(decodable, from: data)
result(.success(object))
} catch {
result(.failure(error))
}
}.resume()
}
}
This is our slightly modified perform
function that now takes a single Requestable
parameter.
Then at the call site we just have:
URLSession.performRequest(on: Object.self) { (result) in
switch result {
case .failure(let error):
print(error)
case .success(let object):
print(object)
}
}
Isn't this so much simpler? All it takes now is just a few lines of code inside your View Controller to fetch new data from a server.
If you don't care about the types of errors you'll get, you could even remove the Result
type and just return an optional, possibly reducing your networking code to three lines.
This is a huge win in readable, reusable code. It's far easier to work with than the older method, and if you're still trying to convince your team to start using Codable, this might just do it.
Photo by Paweł Czerwiński on Unsplash