Understanding Existential Types in Swift
Swift is a language that emphasizes type safety and performance. While we often work with concrete types like String, Int, or UIViewController, there are scenarios where we need to handle values whose specific type isn't known at compile time, but we do know they conform to a particular protocol. This is where existential types come into play.
If you've ever used [someProtocol] or just someProtocol (before Swift 5.6 where any became explicit), you've likely encountered existential types. They allow us to write flexible code that operates on values through their protocol conformance, abstracting away their underlying concrete types. This article will demystify existential types, explain when and how to use them, discuss their implications, and differentiate them from related Swift features like generics and opaque types.
Protocols: The Foundation
Before diving into existentials, let's quickly recap protocols. In Swift, a protocol defines a blueprint of methods, properties, and other requirements that a class, struct, or enum can adopt. When a type adopts a protocol, it agrees to provide an implementation for all the requirements of that protocol.
protocol Drivable {
var speed: Double { get set }
func accelerate(by value: Double)
func brake()
}
struct Car: Drivable {
var speed: Double = 0.0
func accelerate(by value: Double) {
speed += value
print("Car accelerating to \(speed) mph")
}
func brake() {
speed = 0.0
print("Car stopped")
}
}
class Bicycle: Drivable {
var speed: Double = 0.0
func accelerate(by value: Double) {
speed += value / 2 // Bicycles are slower
print("Bicycle accelerating to \(speed) mph")
}
func brake() {
speed = 0.0
print("Bicycle stopped")
}
}
Here, Car and Bicycle are two distinct concrete types, both conforming to Drivable.
What Exactly is an Existential Type?
An existential type is a way to refer to any value whose type conforms to a given protocol, without knowing its specific underlying type. It's essentially a "box" that can hold a value of any type, as long as that type fulfills the requirements of the specified protocol.
In Swift 5.6 and later, you explicitly declare an existential type using the any keyword before the protocol name. For example, any Drivable means "a value of some type that conforms to Drivable."
Consider a scenario where you want a collection of Drivable objects, but you don't care if they are Cars, Bicycles, or something else, as long as they can accelerate and brake.
let myCar = Car()
let myBicycle = Bicycle()
// An array of concrete types (not existential)
let vehicles: [Drivable] = [myCar, myBicycle] // Implicit 'any' conversion pre-Swift 5.6, explicit 'any Drivable' in Swift 5.6+
// Explicitly using 'any'
let anotherVehicle: any Drivable = Car()
let yetAnotherVehicle: any Drivable = Bicycle()
for vehicle in vehicles {
vehicle.accelerate(by: 10.0)
print("Current speed: \(vehicle.speed)")
}
Notice that vehicles is an array of any Drivable. Each element in the array is an existential container holding a value of a specific concrete type (Car or Bicycle), but when you access vehicle within the loop, its specific type is "erased" to any Drivable. You can only call methods and access properties defined by the Drivable protocol.
The Box Metaphor
A good way to visualize an existential type is as a box. This box has a label on the outside indicating the protocol it holds (e.g., any Drivable). Inside the box, there's the actual concrete value (e.g., a Car instance or a Bicycle instance) and some metadata about its specific type.
When you interact with the box, you only see the protocol label. You can ask the box to perform actions defined by the protocol, but you can't ask it to do something specific to the Car type if it happens to contain a Car (unless you cast it back, which defeats some of the purpose of existentials).
┌──────────────────┐
│ Existential Box │
│ (e.g., any Shape)│
├──────────────────┤
│ Runtime Type Info│
│ (e.g., Circle) │
├──────────────────┤
│ Concrete Value │
│ (Circle instance)│
└──────────────────┘
This "boxing" mechanism is what enables existential types to hold values of different concrete types. However, this flexibility comes with a trade-off: runtime overhead.
When to Use Existential Types
Existential types are powerful tools when flexibility and heterogeneity are more important than compile-time type specificity or absolute performance.
- Heterogeneous Collections: Storing a collection of objects that conform to the same protocol but are of different concrete types.
`swift protocol Drawable { func draw() }
struct Circle: Drawable { let radius: Double func draw() { print("Drawing a circle with radius (radius)") } }
struct Square: Drawable { let side: Double func draw() { print("Drawing a square with side (side)") } }
let shapes: [any Drawable] = [Circle(radius: 5.0), Square(side: 10.0)]
for shape in shapes { shape.draw() // Dynamically dispatches to Circle.draw() or Square.draw() } `
- API Flexibility: When designing APIs where callers can provide any type conforming to a protocol, but the API itself doesn't need to be generic over that specific type.
`swift func process(item: any Equatable) { // You can compare 'item' with other 'Equatable' things, // but you don't know its concrete type at compile time. // let anotherItem: any Equatable = 5 // if item == anotherItem { / ... / } // This would require item to be Self, not possible directly. // More on this limitation later. }` (Note:Equatableitself hasSelfrequirements which make it problematic for direct existential use. We'll explore this limitation in a moment.)
- Dependency Inversion: When a high-level module depends on an abstraction (protocol) rather than concrete implementations.
`swift protocol DataFetcher { func fetchData() async throws -> Data }
class NetworkFetcher: DataFetcher { func fetchData() async throws -> Data { print("Fetching data from network...") return Data() // Simulate network call } }
class MockFetcher: DataFetcher { func fetchData() async throws -> Data { print("Fetching data from mock...") return Data([0x01, 0x02]) // Simulate mock data } }
// A view model that depends on 'any DataFetcher' class ViewModel { let fetcher: any DataFetcher
init(fetcher: any DataFetcher) { self.fetcher = fetcher }
func loadContent() async { do { let data = try await fetcher.fetchData() print("Data loaded: (data.count) bytes") } catch { print("Failed to load data: (error)") } } }
let viewModel = ViewModel(fetcher: NetworkFetcher()) // await viewModel.loadContent() // In a real async context
let mockViewModel = ViewModel(fetcher: MockFetcher()) // await mockViewModel.loadContent() // In a real async context ` This pattern is great for testing and swapping implementations without changing the ViewModel itself.
Performance Considerations
The "box" metaphor isn't just an analogy; it's how existential types are often implemented under the hood. When you create an existential type, Swift needs to allocate memory for this box on the heap (if the concrete type's size is unknown or too large for stack allocation) and store the actual value inside it. This process is called boxing.
Additionally, when you call a method on an existential type (e.g., vehicle.accelerate()), Swift cannot know at compile time which specific accelerate implementation to call (Car's or Bicycle's). It has to look up the correct method at runtime using a dynamic dispatch mechanism (typically a vtable or witness table lookup).
Both boxing and dynamic dispatch introduce a small amount of overhead compared to direct calls on concrete types or statically dispatched generic code. For most applications, this overhead is negligible, but in highly performance-critical loops or algorithms, it can become a factor.
Limitations of Existential Types
While flexible, existential types have some notable limitations:
- Associated Types: Protocols with associated types (PATs) cannot be used directly as existential types without the
anykeyword. Withany, you can now use them, but the associated type remains opaque within the existential.`swift protocol Container { associatedtype Item var items: [Item] { get } }
struct IntContainer: Container { typealias Item = Int var items = [1, 2, 3] }
// This is now allowed with 'any' let myContainer: any Container = IntContainer() // print(myContainer.items) // Error: Member 'items' cannot be used on value of type 'any Container'; its 'Self' constraint is not met // This happens because 'items' return type is [Item], where Item is an associated type. // The compiler can't guarantee what Item is for an 'any Container'. ` To access items, you'd typically need to use generics or cast: `swift func processContainer