Check out our Bootcamp Schedule View Schedule

Breaking Down Type Erasure in Swift

Robert Edwards

As a fan of strongly typed programming languages, I love relying on the type system to catch common mistakes and typos early. Occasionally, though, I want to relax the strict rules to gain the payoff of flexibility over safety. Just like a rubber eraser is used to remove written material, a concept known as Type Erasure can be used to remove type information from a program.

Why Would We Remove Type Information?

Let’s start with some background on the Swift programming language.

Swift is a multi-paradigm language, allowing the developer to write constructs in the style of imperative, object-oriented, functional, generic, and protocol-oriented programming. The latter in particular received some special attention from this 2015 WWDC presentation (featuring the now-famous Crusty).

Crusty

The cornerstone of protocol-oriented programming is writing code against a common contract and avoiding the tight coupling associated with inheritance. Be forewarned though, following this pattern, it’s only a matter of time before you hit the following error:

error: protocol 'MyAwesomeProtocol' can only be used as a generic constraint because it has Self or associated type requirements

There is a lot going on in this single compiler error. The error references both Swift’s protocol’s associated types as well as Swift’s generic constraints. In this post we’ll start by illustrating the problem in a concrete example. Next we’ll explore generic constraints and protocol associated types in isolation. Finally we’ll apply the type erasure pattern to work around the error.

What Causes This Error?

This error is caused by attempting to use a protocol, with an associated type, as an argument to a function or as a collection of objects. We’ll use a specific example to highlight the problem. If you’d like to follow along with our playground, hop over to our GitHub repository.

Say we want to display a collection of files and folders in a UITableView. We may also have multiple views for our files and folders, such as a detailed view. We start our development with a Row protocol, defining what our views must supply, and our two concrete data types (Folder and File).

protocol Row {
    associatedtype Model

    var sizeLabelText: String { get set }

    func configure(model: Model)
}

struct Folder {}
struct File {}

Next we create our views for each data type that implements our Row protocol, along with an additional detail view for our File types.

class FolderCell: Row {
    typealias Model = Folder
    var sizeLabelText: String = ""
    func configure(model: Folder) {
        print("Configured a (type(of: self))")
    }
}

class FileCell: Row {
    typealias Model = File
    var sizeLabelText: String = ""
    func configure(model: File) {
        print("Configured a (type(of: self))")
    }
}

class DetailFileCell: Row {
    typealias Model = File
    var sizeLabelText: String = ""
    func configure(model: File) {
        print("Configured a (type(of: self))")
    }
}

Now with our types in place, what we’d like to do is treat our protocol just like any other concrete type. Some examples include holding a reference to a collection of Rows, accessing a random Row, supplying a Row as an argument to a function, or returning an instance of Row from a function.

// Collect an array of Row items
let cells: [Row] = [FileCell(), DetailFileCell()]

// Grab a random instance of Row
let randomFileCell: Row = (arc4random() % 2 == 0) ?
                            FileCell() :
                            DetailFileCell()

// Pass a Row as a function argument
func resize(row: Row) {
    …
}

// or return an instance of Row
func firstRow() -> Row {
    …
}

However attempting any of those actions will result in the aforementioned error: error: protocol 'Row' can only be used as a generic constraint because it has Self or associated type requirements.

How Can We Work Around This?

At first glance it would seem like Swift’s generic types would give us the functionality we’re looking for with our protocol.

// Try making our protocol generic
protocol Row<Model> {
    func configure(model: Model)
}

let cells: Row<File> = [FileCell(), DetailFileCell()]

However you’d be promptly corrected by the compiler with this error: error: Protocols do not allow generic parameters; use associated types instead. Before moving forward with another solution, let’s first examine how Swift’s generic types differ from a Swift protocol’s associated type.

The use of generic types for Swift’s structs, classes & enumerations, as well as associated types for protocols, are all constructs for generalizing functionality. They allow us to write code once that can later be used on multiple concrete types defined elsewhere.

While similar in that regard, generic types and associated types accomplish their abstraction differently and come with various trade offs.

Generic Type Parameters

Generic type parameters are placeholders for types that can be used within a Swift struct, class or enum. The concrete type will be supplied at a later time and at the time of compilation the concrete type will replace the placeholders.

Any code that plans to use the generic type, whether it be create an instance or simply access one, will be aware of the, now concrete, type occupying the placeholder.

For example if we defined a generic struct MyRow<T> like so:

struct MyRow<T> {
    var sizeLabelText: String = ""
    func configure(model: T) {
        print("Configured a (type(of: self))")
    }
}

In order to hold a reference to an instance of MyRow we must be explicit about the type filling in our placeholder.

/// Must be explicit
let myFileRow: MyRow<File> = MyRow<File>()

/// Cannot leave the placeholder unfilled
let myGenericRow = MyRow<T>() /// Error: Use of undeclared type T

The generic placeholder is part of our type’s public API.

Associated Types

Associated types on the other hand, are a different type of placeholder. A protocol is the only Swift type that can make use of Swift’s associated types.

Just like a generic type, the associated type can be used anywhere in the definition of your protocol. The key difference comes from when the placeholder is filled. At the time a concrete type adopts the protocol, it will also supply the concrete type to replace the placeholder (i.e. File in FileCell).

class FileCell: Row {
    typealias Model = **File**
    ...
    func configure(model: **File**) {
        ...
    }
}

Since the adopter of the protocol provides this type, the compiler is not aware of the placeholder’s type when we use an instance of the protocol as an argument in a method, a return type, a type for a var or let and so on.

Hopefully, the error we saw above (e.g., let cells: [Row] = [FileCell(), DetailFileCell()]) makes more sense now. The compiler cannot know what the type is for Model because all it knows about each cell in the array is that it conforms to the Row protocol. The adopter of the protocol is responsible for providing the concrete type to replace Model.

Type erasure will be our ticket around this problem.

Type Erasure Pattern

We can use the type erasure pattern to combine both generic type parameters and associated types to satisfy both the compiler and our flexibility goals.

We will need to create three discrete types in order to satisfy these constraints. This pattern includes an abstract base class, a private box class, and finally a public wrapper class. This same pattern, as diagramed below, is even used in the Swift standard library.

Type Erasure pattern

Abstract Base

The first step of the process is to create an abstract base class. This base class will have three requirements:

  1. Conform to the Row protocol.
  2. Define a generic type parameter Model that serves as Row’s associated type.
  3. Be abstract. (each function must be overridden by a subclass)

This class will not be used directly, but instead subclassed in order to bind the generic type constraint to our protocol’s associated type. We’ve marked this class private as well as by convention prefixing it with an _.

// Abstract generic base class that implements Row

// Generic parameter around the associated type

private class _AnyRowBase<Model>: Row {
    init() {
        guard type(of: self) != _AnyRowBase.self else {
            fatalError("_AnyRowBase<Model> instances can not be created;
          create a subclass instance instead")
        }
    }

    func configure(model: Model) {
        fatalError("Must override")
    }

    var sizeLabelText: String {
        get { fatalError("Must override") }
        set { fatalError("Must override”) }
    }
}

Private Box

Next we create a private Box class with these requirements:

  1. Inherit from the generic base class _AnyRowBase.
  2. Define a generic type parameter Concrete that itself conforms to Row.
  3. Store an instance of Concrete for later usage.
  4. Trampoline each Row protocol function calls to the stored Concrete instance.

This class is referred to as a Box class, because it holds a reference to our concrete implementer of the protocol. In our case this would be a FileCell, FolderCell or DetailFileCell all of which implement Row. We receive the Row protocol conformance from our super class _AnyRowBase and override each function by trampolining the call over to the concrete class. Finally, this class serves a conduit to connect our concrete class’s associated type with our base classes generic type parameter.

// Box class

// final subclass of our abstract base

// Inherits the protocol conformance

// Links Concrete.Model (associated type) to _AnyRowBase.Model (generic parameter)

private final class _AnyRowBox<Concrete: Row>: _AnyRowBase<Concrete.Model> {
    // variable used since we're calling mutating functions
    var concrete: Concrete

    init(_ concrete: Concrete) {
        self.concrete = concrete
    }

    // Trampoline functions forward along to base
    override func configure(model: Concrete.Model) {
        concrete.configure(model: model)
    }

    // Trampoline property accessors along to base
    override var sizeLabelText: String {
        get {
            return concrete.sizeLabelText
        }
        set {
            concrete.sizeLabelText = newValue
        }
    }
}

Public Wrapper

With our private implementation details in place, we need to create a public interface for our type erased wrapper. The naming convention used for this pattern is to prefix Any in front of the protocol you’re wrapping, in our case AnyRow.

This class has the following responsibilities:

  1. Conform to the Row protocol.
  2. Define a generic type parameter Model that serves as Rows associated type.
  3. Within its initializer, take a concrete implementer of the Row protocol.
  4. Wrap the concrete implementer in a private Box _AnyRowBase<Model>.
  5. Trampoline each Row function call along to the Box.

This final piece of the puzzle performs the actual type erasing. We supply the AnyRow class with a concrete implementer of Row (i.e. FileCell) and it erases that concrete type allowing us to work with simply any adopter of Row with matching associated types (i.e. AnyRow<File>).

// Public type erasing wrapper class

// Implements the Row protocol

// Generic around the associated type

final class AnyRow<Model>: Row {
    private let box: _AnyRowBase<Model>

    // Initializer takes our concrete implementer of Row i.e. FileCell
    init<Concrete: Row>(_ concrete: Concrete) where Concrete.Model == Model {
        box = _AnyRowBox(concrete)
    }

    func configure(model: Model) {
        box.configure(model: model)
    }

    var sizeLabelText: String {
        get {
            return box.sizeLabelText
        }
        set {
            box.sizeLabelText = newValue
        }
    }
}

Notice that while our concrete implementer is a property of type _AnyRowBase<Model>, we have to wrap it up in an instance of _AnyRowBox. The only requirement on _AnyRowBox’s initializer is that the concrete class implements Row. This is where the actual type easement occurs. Without this layer in our stack we’d be required to supply the associated type Model to _AnyRowBase<Model> explicitly; and we’d be back to square one.

The Payoff!

The biggest benefit to this process is all the messy boilerplate is an implementation detail. We are left with a relatively simple public API. Let’s revisit our original goals that are now possible with our type erased AnyRow<Model>.

// Collect an array of Row items
let cells: [AnyRow<File>] = [AnyRow(FileCell()),
AnyRow(DetailFileCell())]

// Grab a random instance of Row
let randomFileCell: Row = (arc4random() % 2 == 0) ?
                            AnyRow(FileCell()) :
                            AnyRow(DetailFileCell())

// Pass a Row as a function argument
func resize(row: AnyRow<File>) {
…
}

// Return an instance of Row
func firstRow() -> AnyRow<File> {
…
}

// Configure our collection of cells with a new File
cells.map() {
    $0.configure(model: File())
}

// Mutate and access properties on a Cell
if let firstCell = cells.first {
    firstCell.sizeLabelText = "200KB"
    print(firstCell.sizeLabelText) // Prints 200KB
}

Homogeneous Requirement

One important thing to note is we haven’t lost all of the conveniences and safety of strong typing. With Swift type checker still helping us out we cannot hold an array of just any AnyRow, our Model type must remain consistent.

This differs from how other more loosely typed languages such as Objective-C would handle the situation. The following is perfectly valid in Objective-C:

// Any array containing both a FolderCell and a FileCell
NSArray<Row> \*cells = @[[FolderCell new], [FileCell new]];

Swift, on the other hand, requires our array to be homogeneous around our protocol’s associated type. If we attempt the same line of code in Swift we’re presented with an error:

let cells = [AnyRow(FileCell()), AnyRow(FolderCell())]

error: Heterogeneous collection literal could only be inferred to '[Any]'; add explicit type annotation if this is intentional.

This is a good thing; we’re allowed enough flexibility to work with multiple types conforming to Row but we cannot be bitten by receiving a type we did not expect.

Wrapping Up

Assembling all the pieces required in the type erasure pattern can be overwhelming at first. Fortunately, it’s a formulaic process that will not differ based on your protocol. It’s such a systematic process that the Swift standard library may very well do this for you at some point. See the Swift Evolution’s Completing Generics Manifesto

To explore type erasure concepts in a Swift Playground, check out our accompanying Type Erasure Playgrounds:

Not Happy with Your Current App, or Digital Product?

Submit your event

Let's Discuss Your Project

Let's Discuss Your Project