Type erasure using closures in Swift
Discover page available: GenericsOne of the things that makes Swift so much safer and less error-prone than many other languages is its advanced (and in a way, unforgiving) type system. It’s one of the language features that can at times be really impressive and make you a lot more productive, and at other times be really frustrating.
Today, I wanted to highlight one type of situation that can occur when dealing with generics in Swift, and how I usually solve it using a type erasure technique based on closures.
Let’s say we want to write a class that lets us load a model over the network. Since we don’t want to have to replicate this class for every single model in our application, we choose to make it a generic, like this:
class ModelLoader<T: Unboxable & Requestable> {
func load(completionHandler: (Result<T>) -> Void) {
networkService.loadData(from: T.requestURL) { data in
do {
try completionHandler(.success(unbox(data: data)))
} catch {
let error = ModelLoadingError.unboxingFailed(error)
completionHandler(.error(error))
}
}
}
}
So far so good, we now have a ModelLoader
that is capable of loading any model, as long as it’s Unboxable
, and is able to give us a requestURL
. But, we also want to enable code that uses this model loader to be easily testable, so we extract its API into a protocol:
protocol ModelLoading {
associatedtype Model
func load(completionHandler: (Result<Model>) -> Void)
}
This, together with dependency injection, enables us to easily mock our model loading API in tests. But it comes with a bit of a complication — in that whenever we want to use this API, we now have to refer to it as the protocol ModelLoading
, which has an associated type requirement. This means that simply referring to ModelLoading
is not enough, as the compiler cannot infer its associated types without more information. So trying to do this:
class ViewController: UIViewController {
init(modelLoader: ModelLoading) {
...
}
}
will give us this error:
Protocol 'ModelLoading' can only be used as a generic constraint because it as Self or associated type requirements
But no worries, we can easily get rid of this error by using a generic constraint, to enforce that the concrete type conforming to ModelLoading
will be specified by the API user, and that it will load the kind of model that we’re expecting. Like this:
class ViewController: UIViewController {
init<T: ModelLoading>(modelLoader: T) where T.Model == MyModel {
...
}
}
This works, but since we also want to have a reference to our model loader in our view controller, we need to be able to specify what type that property will be. T
is only known in the context of our initializer, so we can’t define a property with type T
, unless we make the view controller class itself a generic — which will quite quickly make us fall further and futher down into a rabit hole of generic classes everywhere.
Instead, let’s use type erasure, to enable us to save some kind of reference to T
, without actually using its type. This can be done by creating a type erased type, such as a wrapper class, like this:
class AnyModelLoader<T>: ModelLoading {
typealias CompletionHandler = (Result<T>) -> Void
private let loadingClosure: (CompletionHandler) -> Void
init<L: ModelLoading>(loader: L) where L.Model == T {
loadingClosure = loader.load
}
func load(completionHandler: CompletionHandler) {
loadingClosure(completionHandler)
}
}
The above is a type erasure technique that is also quite commonly used in the Swift standard library, for example in the AnySequence
type. Basically you wrap a protocol that has associated value requirements into a generic type, which you can then use without having to keep making everything using it also be generic.
We can now update our ViewController
from before, to use AnyModelLoader
:
class ViewController: UIViewController {
private let modelLoader: AnyModelLoader<MyModel>
init<T: ModelLoading>(modelLoader: T) where T.Model == MyModel {
self.modelLoader = AnyModelLoader(loader: modelLoader)
super.init(nibName: nil, bundle: nil)
}
}
Done! We now have a protocol-oriented API, with easy mockability, that can still be used in a non-generic class, thanks to type erasure 🎉
Now, time for the bonus round. The above technique works really well, but it does involve an extra step that adds a bit of complication to our code. But, turns out, we can actually do the closure-based type erasure directly in our view controller — instead of having to introduce the AnyModelLoader
class. Then, our view controller would instead look like this:
class ViewController: UIViewController {
private let loadModel: ((Result<MyModel>) -> Void) -> Void
init<T: ModelLoading>(modelLoader: T) where T.Model == MyModel {
loadModel = modelLoader.load
super.init(nibName: nil, bundle: nil)
}
}
As with our type erased class AnyModelLoader
, we can refer to the implementation of the load
function as a closure, and simply save a reference to it in our view controller. Now, whenever we want to load a model, we just call loadModel
, like we would any other function or closure:
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadModel { result in
switch result {
case .success(let model):
render(model)
case .error(let error):
render(error)
}
}
}
That’s it! Hope you’ll find the above techniques useful when dealing with generics and protocols in your Swift code 👍
Feel free to reach out to me on Twitter if you have any questions, suggestions or feedback. I’d also love to hear from you if you have any topic that you’d like me to cover in an upcoming post.
Thanks for reading 🚀