Type Erasure Patterns in Swift
Swift is a language that champions strong typing and protocol-oriented programming. Generics and protocols are powerful tools that allow us to write flexible, reusable, and type-safe code. However, there are specific scenarios where these powerful features seem to hit a wall, particularly when working with protocols that declare associatedtype requirements (often called "protocols with associated types" or PATs).
Imagine you're building a system where different components need to conform to a common protocol, but each component might handle a slightly different data type. How do you store these diverse components in a single collection, or pass them around uniformly, without losing type safety or resorting to Any? This is where type erasure comes to the rescue.
In this article, we'll dive deep into type erasure patterns in Swift, understanding the problem they solve, how to implement them, and when to wisely apply them in your iOS or Swift applications.
The Problem: When Protocols Become Too Specific
Let's start by illustrating the problem that type erasure aims to solve. Consider a common pattern: a Validator protocol.
protocol Validator {
associatedtype Value
func isValid(_ value: Value) -> Bool
}
This protocol is wonderfully flexible. We can create concrete validators for different types:
struct EmailValidator: Validator {
func isValid(_ value: String) -> Bool {
return value.contains("@") && value.contains(".") // Simplified
}
}
struct PasswordValidator: Validator {
func isValid(_ value: String) -> Bool {
return value.count >= 8 && value.rangeOfCharacter(from: .letters) != nil && value.rangeOfCharacter(from: .decimalDigits) != nil
}
}
struct AgeValidator: Validator {
func isValid(_ value: Int) -> Bool {
return value >= 18
}
}
Now, suppose you want to create a collection of Validator instances. You might intuitively try to do this:
// This won't compile!
// let stringValidators: [any Validator] = [EmailValidator(), PasswordValidator()]
// let allValidators: [any Validator] = [EmailValidator(), AgeValidator()]
If you try the above, Swift's compiler will greet you with an error like: "Protocol 'Validator' can only be used as a generic constraint because it has Self or associated type requirements."
Why does this happen? When a protocol has an associatedtype, it means the protocol's definition depends on a specific type that the conforming type provides. The Validator protocol, for example, isn't just a Validator; it's a Validator where Value == String or a Validator where Value == Int. The compiler needs to know the specific Value type at compile time to ensure type safety.
When you declare [any Validator], you're asking for a collection where each element could be any Validator, regardless of its Value type. This ambiguity makes it impossible for the compiler to guarantee that isValid(_:) can be called safely on every element, as the Value type might differ.
Swift's Existential Types (any Protocol) and Opaque Types (some Protocol)
Before diving into type erasure, it's worth briefly clarifying Swift's built-in ways to handle protocols:
some Protocol(Opaque Types): Introduced in Swift 5.1,some Protocolis used as a return type to indicate that a function returns a concrete type that conforms to a protocol, but the caller doesn't need to know the exact type. The same concrete type is always returned. It's like an "inverse generic." It doesn't solve the collection problem.any Protocol(Existential Types): Introduced in Swift 5.6,any Protocolexplicitly denotes an existential type, meaning "some value of any concrete type that conforms to this protocol." While it makes the intent clearer, it still cannot be used with protocols that haveSelforassociatedtyperequirements because the compiler cannot guarantee theassociatedtypeat runtime across different concrete types.
This is precisely the gap that type erasure fills.
The Solution: Type Erasure
Type erasure is a design pattern where you wrap a concrete type that conforms to a protocol with associated types inside a non-generic (or generically constrained) wrapper struct or class. This wrapper then conforms to the same protocol, but it "erases" the specific generic details of the wrapped type, presenting a uniform interface.
The wrapper essentially holds a reference to the underlying concrete type and forwards all protocol method calls to it. By making the wrapper generic over the associated types themselves (e.g., AnyValidator<Value>), we effectively fix the associated type, allowing us to store different concrete types that share that same associated type.
Practical Example: Creating AnyValidator
Let's create our AnyValidator type eraser for the Validator protocol:
struct AnyValidator<V>: Validator {
typealias Value = V
// A private closure that captures the 'isValid' method of the wrapped validator
private let _isValid: (V) -> Bool
// The initializer takes any concrete type 'T' that conforms to Validator,
// as long as its 'Value' type matches 'V'.
init<T: Validator>(_ validator: T) where T.Value == V {
_isValid = validator.isValid
}
// Forward the protocol requirement to the captured closure
func isValid(_ value: V) -> Bool {
return _isValid(value)
}
}
Let's break down AnyValidator:
struct AnyValidator<V>: Validator: It's a generic struct, whereVrepresents theValueassociated type of theValidatorprotocol. Crucially,AnyValidatoritself conforms toValidator.typealias Value = V: This line explicitly tells the compiler thatAnyValidator'sValueassociated type is the generic typeV. This "fixes" the associated type forAnyValidator, making it a concrete type for theValidatorprotocol (e.g.,AnyValidator<String>).private let _isValid: (V) -> Bool: This is the core of the type erasure. Instead of storing thevalidatorinstance directly, we store a closure that captures theisValidmethod of the concrete validator passed into the initializer. This allows us to invoke the original validator's logic without needing to know its specific type.init<T: Validator>(_ validator: T) where T.Value == V: The initializer is generic overT, the concrete validator type. Thewhere T.Value == Vclause is vital: it ensures that only validators whoseValuetype matchesAnyValidator'sVcan be wrapped. This preserves type safety.func isValid(_ value: V) -> Bool: This simply calls the stored_isValidclosure, forwarding thevalue.
Now, we can use AnyValidator to solve our collection problem:
let emailValidator = EmailValidator()
let passwordValidator = PasswordValidator()
let ageValidator = AgeValidator() // Value is Int
// Wrap our concrete validators with AnyValidator<String>
let anyEmailValidator = AnyValidator(emailValidator)
let anyPasswordValidator = AnyValidator(passwordValidator)
// Now we can put them into a collection!
let stringValidators: [AnyValidator<String>] = [anyEmailValidator, anyPasswordValidator]
print("--- String Validators ---")
for validator in stringValidators {
print("Is 'test@example.com' valid? \(validator.isValid("test@example.com"))")
print("Is 'short' valid? \(validator.isValid("short"))")
}
// We can also create a collection for Int validators:
let anyAgeValidator = AnyValidator(ageValidator)
let intValidators: [AnyValidator<Int>] = [anyAgeValidator]
print("\n--- Int Validators ---")
for validator in intValidators {
print("Is 17 valid? \(validator.isValid(17))")
print("Is 20 valid? \(validator.isValid(20))")
}
This works beautifully! We've successfully stored different concrete types (EmailValidator, PasswordValidator) in a single collection ([AnyValidator<String>]) by erasing their specific structural types while retaining their shared Value type.
Built-in Type Erasers: AnyHashable and AnyCancellable
You've likely encountered type erasure in Swift even if you didn't recognize it. Two common examples are AnyHashable and AnyCancellable.
AnyHashable: This struct wraps any type that conforms to theHashableprotocol, allowing you to store differentHashabletypes (likeInt,String,UUID, etc.) in a singleSetor use them as keys in aDictionary. WithoutAnyHashable, you'd be restricted toSet<Int>orDictionary<String, ...>.AnyCancellable: Part of the Combine framework,AnyCancellablewraps anyCancellableinstance. This is crucial because Combine's operators return various concreteCancellabletypes.AnyCancellableprovides a unified way to store and manage subscriptions without exposing their intricate underlying types.
These are excellent examples of how type erasure solves common problems in Swift's standard library and frameworks.
When to Use Type Erasure
Type erasure is a powerful tool, but like any powerful tool, it should be used judiciously.
Good Use Cases:
- Heterogeneous Collections: As demonstrated, this is the primary reason. When you need to store multiple concrete types that conform to a protocol with associated types in a single array, set, or dictionary.
- Function Return Types: When a function needs to return an instance conforming to a protocol with associated types, but you don't want to expose the specific concrete type, and
some Protocolisn't suitable (e.g., if the concrete type can vary based on runtime conditions, or if it's a stored property). - Module Boundaries: To create clear abstraction layers between different modules or components. A module can expose an
AnyXYZtype, allowing consumers to interact with it via a fixed interface without coupling to the internal concrete implementations. - Dependency Injection: When injecting dependencies that conform to protocols with associated types, type erasure can simplify the dependency graph by providing a stable, erased type.
Considerations and Trade-offs:
- Increased Complexity: Introducing a type-erased wrapper adds another layer of abstraction and boilerplate code. It can make the code slightly harder to read and debug if not well-documented.
- Runtime Overhead: Type erasure involves dynamic dispatch (calling methods through a closure or vtable), which has a minor runtime performance cost compared to direct method calls. For most applications, this overhead is negligible, but it's worth being aware of in performance-critical sections.
- Loss of Specificity: Once a type is erased, you lose compile-time knowledge of its original concrete type. You cannot, for example, cast an
AnyValidator<String>back toEmailValidatorwithout a runtime check (as? EmailValidator) which defeats some of the compile-time safety benefits.
Comparison: Before vs. After Type Erasure
Let's visualize the impact of type erasure:
Problem (Before Type Erasure):
┌─────────────────────────┐ ┌─────────────────────────┐
│ EmailValidator │ │ PasswordValidator │
│ (Validator, Value=Str)│ │ (Validator, Value=Str) │
└─────────────────────────┘ └─────────────────────────┘
│ │
└───────────┬───────────────┘
│
Compiler Error
│
┌─────────────────────────┐
│ let validators: │ <-- Cannot store different
│ [any Validator] │ concrete types directly
└─────────────────────────┘
Solution (After Type Erasure):
┌─────────────────────────┐ ┌─────────────────────────┐
│ EmailValidator │ │ PasswordValidator │
│ (Validator, Value=Str)│ │ (Validator, Value=Str) │
└─────────────────────────┘ └─────────────────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ AnyValidator<String> │ │ AnyValidator<String> │
│ (wraps EmailValidator) │ │ (wraps PasswordValidator)│
└─────────────────────────┘ └─────────────────────────┘
│ │
└───────────┬───────────────┘
▼
┌─────────────────────────┐
│ let validators: │ <-- Works! Heterogeneous
│ [AnyValidator<String>] │ collection of wrappers
└─────────────────────────┘
Summary
Type erasure is an advanced Swift pattern that empowers you to work with protocols that have associated types in scenarios where Swift's type system would otherwise prevent it, primarily in heterogeneous collections or as function return types. By introducing a generic wrapper that captures the underlying type's behavior, you can achieve flexibility and maintain type safety. While it adds a layer of abstraction and a minimal runtime cost, it's an indispensable tool for building robust and modular Swift applications, especially when dealing with complex architectural patterns.
Happy Swifting!