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:
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?
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:
- Holds an instance of a concrete type that conforms to our protocol
- Forwards operations to that concrete instance
- Hides the specific associated types from the outside world
Implementing Type Erasure
Here's how we can implement type erasure for our FoodEater
protocol:
// 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:
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:
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:
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:
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
:
// 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:
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:
- Create a wrapper class that hides specific type information
- Store a reference to the concrete implementation
- 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
- Swift Documentation on Generics
- WWDC Session: Protocol-Oriented Programming
- Swift by Sundell: Type Erasure
Exercises
- Create a type-erased wrapper for a protocol that represents a mathematical operation with an associated type for the operand.
- Modify the
AnyFilterableCollection
example to add a method that sorts the elements using a provided comparison closure. - 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! :)