engineering

Approaches to Type Erasure in Swift

share

Protocols bring a lot of power and flexibility to our code. Some might say that this is the most important feature of Swift, especially since Apple described it as “Protocol oriented programming language". But every once in a while, things don't work as expected using protocols, and we encounter the following compilation error: `Protocol ‘X’ can only be used as a generic constraint because it has Self or associated type requirements` :oops: In this article we will talk about different approaches to get around this issue.

Imagine that we were creating an app that features movies and gives some useful information about them. We quickly notice that we have a recurring pattern of configuring views using their ViewModels everywhere in our codebase. So we wanted to build a layer of abstraction around this idea by creating a Configurable protocol that can be reused in all parts of our app.

 protocol Configurable {
     associatedtype ViewModel
     func configure(_ model: ViewModel)
 }

In an underlying view of our detail page, we have a stackView containing two types of views, that we’d like to configure (Let's say: CastsView, and KeywordsView for this example).

 class CastsView: UIView, Configurable {
     typealias ViewModel = TagsViewModel
     func configure(_ model: TagsViewModel) {
         …
     }
 }
 
 class KeywordsView: UIView, Configurable {
     typealias ViewModel = TagsViewModel
     func configure(_ model: TagsViewModel) {
         …
     }
 }
 
 // compilation error :(
 let configurables: [Configurable] = [castsView, keywordsView]
 zip(configurables, models).forEach { $0.0.configure($0.1) }

Ideally we would like to be able to configure our views in a simple for-loop using this new Configurable protocol. The problem with this implementation is that, although we can store the views in an Array of UIView, we cannot create an Array of Configurables. The error that the compiler throws is the following: 

Protocol 'Configurable' can only be used as a generic constraint because it has Self or associated type requirements

Indeed, Configurable is a `Protocol with associated type (PAT)`. PATs are more abstract and complex than a normal protocol, and currently in Swift, PATs can only be used as a generic constraint.

In this article we will illustrate different ways of implementing “Type Erasure” to overcome this problem. Type Erasure is a workaround that is used extensively in the Swift standard library and frameworks such as Combine. There is a lot of work still going on to enhance generics and PATs in Swift, hopefully it will make them easier to work with.

We’ll see two different approaches to implement Type erasure, the first one involves a technique called Boxing, And the second one involves what we can refer to as Shadowing.

Type erasure using boxing:

Boxing addresses compile time protocol limitations to be deferred until runtime by using a container type.

The fact that our container is a concrete type that implements Configurable allows us to create an Array of that type, we can then have an array of GenericConfigurables.

 class GenericConfigurable<T>: Configurable {
     typealias ViewModel = T
     private let configurationClosure: (T) -> Void
 
     init<U: Configurable>(_ configurable: U) where U.ViewModel == T {
         configurationClosure = configurable.configure
     }
 
     func configure(_ model: T) {
         configurationClosure(model)
     }
 }


 let configurables = [
     GenericConfigurable(keywordsView),
     GenericConfigurable(castsView)
 ]
 zip(configurables, models).forEach { $0.0.configure($0.1) }

The steps to implement this are fairly simple:

  •  First, we introduce a generic container Type GenericConfiguration conforming to Configurable
  •  The container accepts a Configurable generic that has the same ViewModel as its own so it can store its configuration method
  •  We store the configure method of the Configurable init argument in our configurationClosure property
  •  Whenever configure is called, we call our configurationClosure that has the real implementation

Note that we have to store every single method that is defined in the erased protocol type. In this case we were lucky because Configurable only needs one.

Now imagine that we need to have multiple other views to display that implement Configurable, each one of them having a different ViewModel. The problem is that we cannot insert them in the previous array of configurables since it only accepts an element of type “GenericConfigurable<TagsViewModel>”. 

 class SummaryView: UIView, Configurable {
     typealias ViewModel = SummaryViewModel
     func configure(_ model: SummaryViewModel) {
         …
     }
 }
 // Compilation error: Initializer 'init(_:)' requires the types 'TagsViewModel' and  
 // 'SummaryView.ViewModel' (aka 'SummaryViewModel') to be equivalent
 let configurables: [GenericConfigurable<TagsViewModel>] = [
     GenericConfigurable(keywordsView),
     GenericConfigurable(castsView),
     GenericConfigurable(summaryView)
 ]

In this case we’d want a container type that has no constraints over the ViewModel of its mirrored type. We can achieve that using the type ‘Any’ that can represent an instance of any type at all.

 class AnyConfigurable: Configurable {
     typealias ViewModel = Any
     private let configurationClosure: (Any) -> Void
 
     init<T: Configurable>(_ configurable: T) {
         configurationClosure = { model in
             guard let model = model as? T.ViewModel else { return }
             configurable.configure(model)
         }
     }
 
     func configure(_ model: Any) {
         configurationClosure(model)
     }
 }

Since our container’s configure method can now take any type of argument, we have to downcast it to the concrete type of our underlying configurable’s ViewModel so we can pass it as an argument to the real implementation. This then allows us to write the following concise code (with models of type [Any]):

 let configurables = [
     AnyConfigurable(keywordsView),
     AnyConfigurable(castsView),
     AnyConfigurable(summaryView)
 ]
 zip(configurables, models).forEach { $0.0.configure($0.1) }

And this is how you can apply type erasure using boxing :tada: ! The same technique would allow us to store a Set of type erased objects that conform to Hashable for example.

This working solution is really close to what we’d want for our layer of abstraction that can be used in any other Views and ViewControllers, but it has some limitations. Firstly, we need to instantiate a new type eraser every single time we want to use our views for configuration, which adds boilerplate code in several parts of our codebase

 let configurables = [castsView, keywordsView, summaryViewModel]
 // VS
 let configurables = [
     AnyConfigurable(keywordsView),
     AnyConfigurable(castsView),
     AnyConfigurable(summaryView)
 ]

Secondly,  we cannot use our type eraser if our views are contained in a stackView as follows:

// Compilation error: Cannot invoke initializer for type 'AnyConfigurable<_>' with an argument list
// of type '(UIView)'
 let configurables: [AnyConfigurable] = stackView.arrangedSubviews.map { AnyConfigurable($0) }

The problem is that AnyConfigurable takes a concrete type that conforms to Configurable, and we lost this type information by casting our views to UIView. It is certainly possible to test downcasting to our custom views but it’s not really suitable when we have a lot of possible outcomes.

And last but not least, the viewModels are now contained in an array of type ‘[Any]’ which is not type safe since there can be anything in the array. This means that we are not guaranteed that our views are configured with the proper needed type. To overcome these limitations we'll try to use another kind of type erasure.

Shadow type erasure:

We usually use the term shadowing for two or more variables created in overlapping scopes. When a variable in the outer scope is hidden by one in the inner scope, we say that the first one is “shadowed” by the second one. In this case, we can say that the context of the execution is the scope and the shadowed definition is that of the first variable. We will try to apply the same logic to create our type eraser, the context will be the type of the protocol that is used, and the shadowed definition will be that of our configure method. For that, we create a new protocol that will allow us to conceal our Configurable PAT.

 protocol ShadowConfigurable {
     func configure(_ model: Any)
 }
 
 protocol Configurable: ShadowConfigurable {
     …
 }

Then we can take advantage of the fact that method dispatch in protocol extensions are always performed statically (i.e: at compile-time), using the most accurate method definition to be found in the scope of the extension, which saves us from creating a second configuration method or falling into an infinite loop when performing the following operation:

  // we can also just use `extension Configurable {` but I find this version more expressive
 extension ShadowCellConfigurable where Self: Configurable {
     func configure(_ model: Any) {
         guard let model = model as? ViewModel else { return }
         configure(model)
     }
 }

With these simple modifications we can begin to use our new API:

 let configurables = stackView.arrangedSubviews.compactMap { $0 as?  ShadowConfigurable }
 zip(configurables, models).forEach { $0.0.configure($0.1) }

As a result of our new implementation we can also combine multiple strictly typed boxed type erasers to create an array of shadowConfigurables:

 let tagsViews: [AnyConfigurable<TagsViewModel>] = ...
 let summaryViews: [AnyConfigurable<SummaryViewModel>] = ...
 let configurables: [ShadowConfigurable] = tagsViews + summaryViews
 zip(configurables, models).forEach { $0.0.configure($0.1) }

Nice! We’ve managed a great deal of abstraction so far, but maybe a little too much by taking “Any” as an argument in our new protocol’s configure method.

To narrow the scope of types taken by our Shadow protocol we’ll break its inheritance relationship with Configurable, give it a more specific naming, and make our views conform to it. This way, we could use other protocols for different sets of viewModels in other parts of our app.

protocol DetailViewElementConfigurable {
    func configure(_ model: DetailElementViewModel)
}
 
extension CastsView: DetailViewElementConfigurable {}
extension KeywordsView: DetailViewElementConfigurable {}
extension SummaryView: DetailViewElementConfigurable {}

We can define our viewModelType by using an enum:

 enum DetailElementViewModel {
     case summary(SummaryViewModel)
     case tag(tagViewModel)
     etc…
 }
 
 extension DetailViewElementConfigurable where Self: Configurable {
     func configure(_ model: DetailElementViewModel) {
         switch  model {
         case .summary(let summary):
             configure(summary)
         case .tag(let tagViewModel):
             configure(tag)
         etc…
         }
     }
 }

This is a quick solution to the problem, but enums don’t scale well, and maintaining a large number of cases is annoying. What if we wanted to return the viewModel’s Id ? We’d have to switch again through all cases. A second solution is to use a protocol instead:

 protocol DetailElementViewModel {
     typealias ID = UUID
     var id: ID { get }
 }
 
 extension TagsViewModel: DetailElementViewModel {}
 extension KeywordsViewModel: DetailElementViewModel {}
 extension SummaryViewModel: DetailElementViewModel {}
 
 extension Configurable where ViewModel: DetailElementViewModel {
     func configure(_ model: DetailElementViewModel) {
         guard let model = model as? ViewModel else { return }
         configure(model)
     }
 }

Another improvement would be error handling. Right now if we try to configure the view with the wrong viewModel, it just does nothing, it would be better to raise an error so we can fix our code whenever this happens. For that we can use an assertion with a detailed message:

 func configure(_ model: DetailElementViewModel) {
     guard let model = model as? ViewModel else {
         assertionFailure("Invalid ViewModel, Expecting type \(ViewModel.self), received \(model) ")
         return
     }
     configure(model)
 }

With this pattern in place, we can begin to use our new Configurable protocol throughout our codebase.

Conclusion:

In this article we’ve seen that protocols with associated types are very constrained to work with. We’ve also seen how we can take advantage of different kinds of type-erasures to workaround the issues we face with PATs.

In Swift, PATs are not meant to be used inside an array. The main purpose of creating a Protocol with associated types is to create a common API to describe a generalized concept that can be implemented using different algorithms according to the associated types. Then we can take advantage of this common API as a generic constraint to implement algorithms inside a method definition or an extension. Take `Equatable` for example, when a custom type conforms to it, you define your own implementation of the `==` operator, (in fact, most of the time, the compiler does it for you), but then, as a result, your custom type get to be used in a whole range of algorithms that rely on this idea of equality. You could call `contains` in an array of your new type for example.

When you hold a hammer, everything starts looking like nails. It can be tempting to use protocols, PATs and type erasure everywhere in your app, however a common trap is to start using a protocol before knowing what it is you truly need. As a result you end up adding a new layer of complexity to your code. In fact, we should think twice before using them, and be really sure that it is the right level of abstraction that is required for our use case.

related expertises