How to use type erasure in Swift with a real example
Unlocking flexibility and reusability by mastering type erasure in Swift
One of the remarkable attributes of Swift is its strong focus on type safety, which ensures a robust and reliable codebase.
However, we might find ourselves in situations where we need a little more flexibility. We might want to hide or abstract a specific value type to make our code more generic and reusable. We want to type erasure.
This is a powerful technique that, as an iOS developer, you should master. Because, sooner or later, you will need to use it.
Let’s jump into a practical example to see how it works.
Our coffee shop app allows our customers to add coffee drinks and food to their orders. As we have different sizes for the coffees than for the food, we could model our entities as follow:
struct Coffee {
enum CoffeeType {
case latte
case cappuccino
case mocha
}
enum CoffeeSize {
case small
case medium
case large
}
let type: CoffeeType
let size: CoffeeSize
private let prices: [CoffeeType: [CoffeeSize: Float]] = [
.latte: [
.small: 4.5,
.medium: 5.5,
.large: 6.0
],
.cappuccino: [
.small: 4.5,
.medium: 5.5,
.large: 6.0
],
.mocha: [
.small: 4.5,
.medium: 5.5,
.large: 6.0
]
]
var price: Float {
return prices[type]?[size] ?? 0.0
}
}
struct Food {
enum FoodType {
case croissant
case muffin
case sandwich
}
enum FoodSize {
case small
case regular
}
let type: FoodType
let size: FoodSize
private let prices: [FoodType: [FoodSize: Float]] = [
.croissant: [
.small: 8,
.regular: 12,
],
.muffin: [
.small: 6.5,
.regular: 8,
],
.sandwich: [
.regular: 8.5
]
]
var price: Float {
return prices[type]?[size] ?? 0.0
}
}
The Problem
With this approach, we must hold two different lists for our menu to build our view.
- One for the coffee
- One for the food
let coffeeMenu: [Coffee] = [
Coffee(type: .latte, size: .small),
Coffee(type: .latte, size: .regular),
Coffee(type: .latte, size: .large),
Coffee(type: .cappuccino, size: .small),
Coffee(type: .cappuccino, size: .regular),
Coffee(type: .cappuccino, size: .large),
Coffee(type: .mocha, size: .small),
Coffee(type: .mocha, size: .regular),
Coffee(type: .mocha, size: .large),
]
let foodMenu: [Food] = [
Food(type: .croissant, size: .small),
Food(type: .croissant, size: .regular),
Food(type: .muffinn, size: .small),
Food(type: .muffinn, size: .regular),
Food(type: .sandwich, size: .regular)
]
struct HomeView: View {
...
var body: some View {
...
ScrollView {
ForEach(coffeeMenu, id: \.self) { cofee in
// Configure UI for coffee
}
ForEach(foodMenu, id: \.self) { food in
// Configure UI for food
}
}
}
If we pay attention, the UI for both coffee and food is pretty similar. The main difference is when we tap on one item and show each size.
How do we adjust it?
Using protocols and generics.
This is where type erasure comes into place.
The main idea is to define a common interface so that both Coffee and Food structures implement it. Then we can create only one list to define our menu and use it in our view.
protocol MenuItem {
associatedtype ItemSize
func price(for size: ItemSize) -> Float
}
extension Coffee: MenuItem {
enum ItemSize {
case small
case medium
case large
}
func price(for size: ItemSize) -> Float {
return prices[type]?[size] ?? 0
}
var prices: [CoffeeType: [ItemSize: Float]] {
return [
.latte: [
.small: 4.5,
.medium: 5.5,
.large: 6.0
],
.cappuccino: [
.small: 4.5,
.medium: 5.5,
.large: 6.0
],
.mocha: [
.small: 4.5,
.medium: 5.5,
.large: 6.0
]
]
}
}
extension Food: MenuItem {
enum ItemSize {
case small
case regular
}
func price(for size: ItemSize) -> Float {
prices[type]?[size] ?? 0
}
private var prices: [FoodType: [ItemSize: Float]] {
return [
.croissant: [
.small: 8,
.regular: 12,
],
.muffin: [
.small: 6.5,
.regular: 8,
],
.sandwich: [
.regular: 8.5
]
]
}
}
let menu: [MenuItem] = [
Coffee(type: .latte),
Coffee(type: .cappuccino),
Coffee(type: .mocha),
Food(type: .croissant),
Food(type: .muffin),
Food(type: .sandwich),
]
If we try to execute the above code, we’re going to get an error from the compiler. This happens because the compiler does not know what type ItemSize
is.
Even though the compiler will know for sure the type in runtime, it can’t be resolved in compile time. As Swift is meant to be type-safe, we get the error.
How do we solve it?
Creating a wrapper that hides MenuItem
implementations.
struct AnyMenuItem {
private let _description: String
private let _sizes: [Any]
private let _price: (Any) -> Float
init<T: MenuItem>(_ menuItem: T) {
_description = menuItem.description
_sizes = menuItem.sizes as [Any]
_price = { size in
guard let itemSize = size as? T.ItemSize else {
fatalError("Invalid item size")
}
return menuItem.price(for: itemSize)
}
}
var description: String {
return _description
}
var sizes: [Any] {
return _sizes
}
func price(for size: Any) -> Float {
return _price(size)
}
}
let menu: [AnyMenuItem] = [
AnyMenuItem(Coffee(type: .latte)),
AnyMenuItem(Coffee(type: .mocha)),
AnyMenuItem(Food(type: .croissant)),
AnyMenuItem(Food(type: .muffin)),
]
for item in menu {
print("Prices for \(item.description):")
for size in item.sizes {
print("- \(size): $\(item.price(for: size))")
}
print("----------------------------")
}
// Output👇🏻
Prices for Latte:
- small: $4.5
- medium: $5.5
- large: $6.0
----------------------------
Prices for Mocha:
- small: $4.5
- medium: $5.5
----------------------------
Prices for Croissant:
- small: $8.0
- regular: $12.0
----------------------------
Prices for Muffin:
- regular: $8.0
----------------------------
This is what it’s called type erasure. By using AnyMenuItem
we're hiding the real type that we want to use. In our case: Coffee and Food.
With this approach, we gain on abstraction, but we have a big downside: the use of Any
in AnyMenuItem
.
Consider the following code:
let menu: [AnyMenuItem] = [
AnyMenuItem(Coffee(type: .latte)),
AnyMenuItem(Food(type: .muffin)),
]
for item in menu {
print(item.price(for: Coffee.ItemSize.small))
}
The code will compile just fine and we won’t get any errors from the compiler. However, if we execute, we’ll get a crash: Invalid item size.
Why? Because Coffee.ItemSize.small
is not compatible with Food.ItemSize
.
Lucky us
From Swift 5.7 we can use the new keyword any
to make our type erasure process type-safe. Using it can avoid the “manual” erasure we did with AnyMenuItem
struct.
let menu: [any MenuItem] = [
Coffee(type: .latte),
Coffee(type: .mocha),
Food(type: .croissant),
Food(type: .muffin),
]
for item in menu {
print("Prices for \(item.description)")
for pricelist in getPriceList(for: item) {
print("- \(pricelist.size): $\(pricelist.price)")
}
}
func getPriceList<T: MenuItem>(for item: T) -> [(size: T.ItemSize, price: Float)] {
var prices: [(T.ItemSize, Float)] = []
for size in item.sizes {
prices.append((size: size, price: item.price(for: size)))
}
return prices
}
// Output👇🏻
Prices for Latte
- small: $4.5
- medium: $5.5
- large: $6.0
Prices for Mocha
- small: $4.5
- medium: $5.5
Prices for Croissant
- small: $8.0
- regular: $12.0
Prices for Muffin
- regular: $8.0
Yep, that’s it.
Notice: If we attempt to call
item.price(for: size)
directly, we will get an error. Apparently, the compiler needs to be sure of the type that we're using (ItemSize
in our case). That's why we need to use a generic function (getPriceList<T: MenuItem>(for:T)
) to fulfill the necessary type constraints.
Takeaways
- We can create reusable code that is decoupled from specific types, making it easier to maintain and extend.
- Hide the specific type of a value and work with it in a more generic and flexible manner.
- If you’re using Swift 5.7 o higher, take advantage of the built-in type erasure that Swift provides us by using
any
instead of creating a wrapper. - Type erasure is not a fit for every case, you should carefully evaluate if it's the right technique to use.