Skip to main content

Swift Type Erasure

Introduction

Type erasure is an advanced Swift pattern that helps us manage complexity when working with protocols that have associated types or Self requirements. While Swift's strong type system provides many benefits, sometimes it creates constraints that make certain designs difficult to implement. Type erasure offers a solution by "erasing" specific type information while preserving the functionality we need.

In this tutorial, you'll learn:

  • What type erasure is and why it's needed in Swift
  • How protocol limitations create the need for type erasure
  • How to implement the type erasure pattern
  • Real-world applications of type erasure

The Problem: Protocol Limitations

Let's start by understanding why we need type erasure in the first place. Consider a simple protocol for an animal that can make a sound:

swift
protocol Animal {
func makeSound()
}

struct Dog: Animal {
func makeSound() {
print("Woof!")
}
}

struct Cat: Animal {
func makeSound() {
print("Meow!")
}
}

// We can create an array of Animals
let animals: [Animal] = [Dog(), Cat()]
animals.forEach { $0.makeSound() }

This works fine. But what happens when we add an associated type to our protocol?

swift
protocol FoodEater {
associatedtype FoodType
func eat(_ food: FoodType)
}

struct Dog: FoodEater {
// Dogs eat DogFood
typealias FoodType = DogFood
func eat(_ food: DogFood) {
print("Dog is eating \(food)")
}
}

struct Cat: FoodEater {
// Cats eat CatFood
typealias FoodType = CatFood
func eat(_ food: CatFood) {
print("Cat is eating \(food)")
}
}

struct DogFood { }
struct CatFood { }

// This won't compile!
// let animals: [FoodEater] = [Dog(), Cat()] // Error!

The code above doesn't compile because FoodEater is a protocol with an associated type, which makes it what Swift calls an "existential type" - it can't be used as a concrete type for variables or parameters.

Understanding Type Erasure

Type erasure is a technique that allows us to "erase" the specific associated type while keeping the functionality we need. We do this by creating a type-erased wrapper class that:

  1. Holds an instance of a concrete type that conforms to our protocol
  2. Forwards operations to that concrete instance
  3. Hides the specific associated types from the outside world

Implementing Type Erasure

Here's how we can implement type erasure for our FoodEater protocol:

swift
// 1. Create a box class that will be the base for our type erasure
class AnyFoodEaterBase<T> {
func eat(_ food: T) {
fatalError("This method must be overridden")
}
}

// 2. Create a box implementation that wraps a concrete FoodEater
class AnyFoodEaterBox<Concrete: FoodEater>: AnyFoodEaterBase<Concrete.FoodType> {
private let _base: Concrete

init(_ base: Concrete) {
self._base = base
}

override func eat(_ food: Concrete.FoodType) {
_base.eat(food)
}
}

// 3. Create our public type-erased class
class AnyFoodEater<T> {
private let _box: AnyFoodEaterBase<T>

init<U: FoodEater>(_ foodEater: U) where U.FoodType == T {
_box = AnyFoodEaterBox(foodEater)
}

func eat(_ food: T) {
_box.eat(food)
}
}

Now we can use our type-erased wrapper:

swift
let dogEater = AnyFoodEater(Dog())
let catEater = AnyFoodEater(Cat())

dogEater.eat(DogFood()) // Output: Dog is eating DogFood
catEater.eat(CatFood()) // Output: Cat is eating CatFood

// We still can't create an array of different AnyFoodEater types
// because they have different generic parameters
// But we've made progress!

A Complete Example: Type-Erased Collections

Let's create a more practical example. We'll define a FilterableCollection protocol that lets us filter items in a collection:

swift
protocol FilterableCollection {
associatedtype Element
var items: [Element] { get }
func filtered(by predicate: (Element) -> Bool) -> [Element]
}

// A concrete implementation for Int collections
struct IntCollection: FilterableCollection {
typealias Element = Int
var items: [Int]

func filtered(by predicate: (Int) -> Bool) -> [Int] {
return items.filter(predicate)
}
}

// A concrete implementation for String collections
struct StringCollection: FilterableCollection {
typealias Element = String
var items: [String]

func filtered(by predicate: (String) -> Bool) -> [String] {
return items.filter(predicate)
}
}

Now, let's create a type-erased wrapper:

swift
class AnyFilterableCollection<T> {
private let _items: [T]
private let _filteredMethod: ((T) -> Bool) -> [T]

// Constructor that takes any FilterableCollection with matching Element type
init<C: FilterableCollection>(_ collection: C) where C.Element == T {
self._items = collection.items
self._filteredMethod = collection.filtered
}

// Forward the property access
var items: [T] {
return _items
}

// Forward the method call
func filtered(by predicate: (T) -> Bool) -> [T] {
return _filteredMethod(predicate)
}
}

Now we can use our type-erased collections:

swift
let intCollection = IntCollection(items: [1, 2, 3, 4, 5])
let stringCollection = StringCollection(items: ["apple", "banana", "cherry"])

let typeErasedIntCollection = AnyFilterableCollection(intCollection)
let typeErasedStringCollection = AnyFilterableCollection(stringCollection)

let evenNumbers = typeErasedIntCollection.filtered { $0 % 2 == 0 }
print(evenNumbers) // Output: [2, 4]

let longFruits = typeErasedStringCollection.filtered { $0.count > 5 }
print(longFruits) // Output: ["banana", "cherry"]

// We can now store different types in variables of the same type:
func processCollection<T>(_ collection: AnyFilterableCollection<T>) {
print("Collection has \(collection.items.count) items")
}

processCollection(typeErasedIntCollection) // Output: Collection has 5 items
processCollection(typeErasedStringCollection) // Output: Collection has 3 items

Real-World Application: Working with UIKit

Type erasure is used extensively in Apple's frameworks. For example, AnyHashable is a type-erased wrapper around the Hashable protocol. Let's see how we might use type erasure when working with UITableView:

swift
// First, let's define a protocol for sections in a table view
protocol TableViewSection {
associatedtype CellModel
var title: String { get }
var items: [CellModel] { get }
func configure(_ cell: UITableViewCell, with model: CellModel)
}

// Now let's define our type-erased wrapper
class AnyTableViewSection<T>: TableViewSection {
typealias CellModel = T

private let _title: String
private let _items: [T]
private let _configureCell: (UITableViewCell, T) -> Void

init<S: TableViewSection>(_ section: S) where S.CellModel == T {
self._title = section.title
self._items = section.items
self._configureCell = section.configure
}

var title: String {
return _title
}

var items: [T] {
return _items
}

func configure(_ cell: UITableViewCell, with model: T) {
_configureCell(cell, model)
}
}

// Now we can define concrete sections:
struct UserSection: TableViewSection {
typealias CellModel = User
var title: String { return "Users" }
var items: [User]

func configure(_ cell: UITableViewCell, with model: User) {
cell.textLabel?.text = model.name
cell.detailTextLabel?.text = model.email
}
}

struct SettingSection: TableViewSection {
typealias CellModel = Setting
var title: String { return "Settings" }
var items: [Setting]

func configure(_ cell: UITableViewCell, with model: Setting) {
cell.textLabel?.text = model.name
cell.accessoryType = model.enabled ? .checkmark : .none
}
}

// Define our models
struct User {
let name: String
let email: String
}

struct Setting {
let name: String
let enabled: Bool
}

Now we can use our type-erased sections in a view controller:

swift
class MyTableViewController: UITableViewController {
// We can store different section types in the same array!
var sections: [Any] = []

override func viewDidLoad() {
super.viewDidLoad()

let users = [
User(name: "John", email: "[email protected]"),
User(name: "Jane", email: "[email protected]")
]

let settings = [
Setting(name: "Notifications", enabled: true),
Setting(name: "Dark Mode", enabled: false)
]

// Add our type-erased sections
sections.append(AnyTableViewSection(UserSection(items: users)))
sections.append(AnyTableViewSection(SettingSection(items: settings)))
}

// TableView data source methods would use our sections array
// For brevity, we're not implementing them here
}

Summary

Type erasure is a powerful technique that helps us work around Swift's protocol limitations while maintaining type safety. Although the implementation can appear complex, the pattern is relatively straightforward:

  1. Create a wrapper class that hides specific type information
  2. Store a reference to the concrete implementation
  3. Forward method calls and property access to the concrete instance

Key benefits of type erasure include:

  • Working with protocols that have associated types
  • Creating collections of different types that conform to the same protocol
  • Hiding implementation details from clients
  • Building more flexible APIs

Type erasure is commonly used in Swift frameworks and is a pattern you'll encounter frequently as you work with more advanced Swift code.

Additional Resources

Exercises

  1. Create a type-erased wrapper for a protocol that represents a mathematical operation with an associated type for the operand.
  2. Modify the AnyFilterableCollection example to add a method that sorts the elements using a provided comparison closure.
  3. Create a type-erased wrapper for a protocol called DataProvider that has an associated type for the data it provides and a method to fetch that data.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)