Swift Type Erasure: Hide Concrete Types Behind a Common API
Swift type erasure is a technique for hiding a concrete type behind a stable interface, so you can store, pass around, or return values without exposing the exact underlying type. It is especially useful when protocols have associated types or when you want to keep an API flexible while still preserving type safety.
Quick answer: Type erasure lets you turn different concrete types into one shared wrapper type, such as AnySequence or a custom erased protocol wrapper. Use it when Swift’s static type system prevents you from using a protocol directly as a value.
Difficulty: Advanced
You'll understand this better if you know: basic Swift generics, protocols, associated types, and how closures and structs work.
1. What Is Swift Type Erasure?
Type erasure is a design pattern that removes the visible concrete type from a value while preserving the operations you need. In Swift, this usually means wrapping a generic or protocol-based value inside another type that exposes a simpler, non-generic API.
- It hides implementation details.
- It lets you store different concrete types in one collection or property.
- It helps when a protocol has an associatedtype or Self requirements.
- It keeps the public API stable even if the internal implementation changes.
A common example is AnySequence, which can wrap many different sequence types behind one type name.
2. Why Type Erasure Matters
Swift prefers static type information, which gives you safety and performance. But that strength also creates a problem: some useful abstractions cannot be used directly as values. Type erasure bridges that gap.
It matters when you want one of these outcomes:
- Return different concrete types from one function using the same return type.
- Store heterogeneous implementations in a property or array.
- Hide internal generics from callers.
- Build framework-style APIs that are easier to consume.
Without type erasure, you may be forced to expose a generic parameter everywhere or duplicate APIs for each concrete type. Type erasure gives you a cleaner public surface.
3. Basic Syntax or Core Idea
Type erasure is not a single keyword in Swift. It is a pattern built with wrappers, closures, forwarding methods, and sometimes boxes. The wrapper captures the real type and forwards work to it.
A minimal wrapper idea
The simplest form stores behavior in closures. Here is the core shape:
struct AnyPrinter {
private let _print: () -> Void
init<P: CustomStringConvertible>(_ value: P) {
self._print = {
print(value.description)
}
}
func printValue() {
_print()
}
}This wrapper stores the operation instead of exposing the exact type. Real Swift type erasure wrappers are usually more specialized, but the idea is the same.
4. Step-by-Step Examples
Example 1: Why a protocol with associated types cannot be used directly
Suppose you have a protocol that describes a data source, but each implementation uses a different item type. You cannot use such a protocol as a plain value because Swift needs to know the concrete associated type.
protocol DataSource {
associatedtype Item
func item(at index: Int) -> Item
}
struct IntSource: DataSource {
func item(at index: Int) -> Int { index * 2 }
}
let source: any DataSource = IntSource()If you try to use source as a plain value and access Item, Swift limits what you can do because the associated type is no longer concrete from the caller’s point of view.
Example 2: Building a type-erased wrapper
One way to solve this is to create a wrapper that erases the concrete source type and exposes only the behavior you need.
struct AnyDataSource<Item> {
private let _itemAt: (Int) -> Item
init<S: DataSource>(_ source: S) where S.Item == Item {
self._itemAt = source.item(at:)
}
func item(at index: Int) -> Item {
_itemAt(index)
}
}
let erased = AnyDataSource(IntSource())
let value = erased.item(at: 3)The wrapper preserves the usable behavior while hiding the original source type.
Example 3: Returning different concrete types from one function
Imagine a function that sometimes returns one collection type and sometimes another. A single concrete return type can be awkward, but an erased wrapper makes the API uniform.
func makeNumbers(useArray: Bool) -> AnySequence<Int> {
if useArray {
return AnySequence([1, 2, 3])
} else {
return AnySequence(4...6)
}
}Both branches produce different underlying sequence types, but the caller sees only AnySequence<Int>.
Example 4: Storing heterogeneous implementations in a property
Type erasure is often used when a property must hold values from multiple implementations that share behavior.
protocol FormatterProtocol {
func format() -> String
}
struct ShortFormatter: FormatterProtocol {
func format() -> String { "short" }
}
struct AnyFormatter {
private let _format: () -> String
init<F: FormatterProtocol>(_ formatter: F) {
self._format = formatter.format
}
func format() -> String {
_format()
}
}
let formatter: AnyFormatter = AnyFormatter(ShortFormatter())This pattern keeps the stored property simple even if the underlying formatter changes later.
5. Practical Use Cases
- Hiding the concrete sequence type in APIs that return Sequence-like values.
- Storing different conforming types in arrays, properties, or caches.
- Exposing a protocol-based service while keeping its internal implementation private.
- Bridging complex generic code into simpler public interfaces.
- Creating wrapper types for libraries and frameworks where implementation details should not leak.
Type erasure is most useful when you need abstraction without losing the ability to call the important methods.
6. Common Mistakes
Mistake 1: Trying to use a protocol with associated types as a plain type
Protocols with associated types are often the first place people need type erasure. Swift cannot always treat them as a fully usable value because the associated type is unknown at compile time.
Problem: This fails because DataSource has an associated type, so Swift cannot infer a single concrete item type for the stored value.
protocol DataSource {
associatedtype Item
func item(at index: Int) -> Item
}
let source: DataSourceFix: Add a type-erased wrapper that pins down the associated type and forwards the behavior you need.
let source: AnyDataSource<Int> = AnyDataSource(IntSource())The wrapper gives Swift a concrete storage type while still hiding the original implementation.
Mistake 2: Erasing too much and losing useful capabilities
Type erasure should hide implementation details, not essential behavior. If your wrapper exposes only a tiny subset of methods, you may make the API harder to use than the original type.
Problem: The wrapper hides everything except one method, so code that needs more than that method becomes impossible without downcasting or redesign.
struct AnyLogger {
private let _log: (String) -> Void
init<L: CustomStringConvertible>(_ logger: L) {
self._log = { message in
print(message)
}
}
}Fix: Erase only the API surface your callers truly need, and keep the wrapper aligned with the protocol’s important operations.
protocol Logger {
func log(_ message: String)
}
struct AnyLogger {
private let _log: (String) -> Void
init<L: Logger>(_ logger: L) {
self._log = logger.log
}
func log(_ message: String) {
_log(message)
}
}The best wrapper preserves the behavior that matters instead of flattening the API too aggressively.
Mistake 3: Recreating the wrapper on every call instead of storing it
Type erasure can add overhead if you build a new wrapper repeatedly in a hot path. In many cases, the wrapper should be created once and reused.
Problem: Creating a new erased wrapper every time a method runs can add unnecessary allocations or closure captures, especially in tight loops.
func sumFirstThree<S: Sequence>(_ sequence: S) -> Int where S.Element == Int {
let erased = AnySequence(sequence)
return erased.prefix(3).reduce(0, +)
}Fix: If the erased value is reused, store it as a property or create it at the boundary of your API rather than inside frequently called code.
struct IntSequenceConsumer {
let sequence: AnySequence<Int>
func sumFirstThree() -> Int {
sequence.prefix(3).reduce(0, +)
}
}This approach keeps the cost at the boundary and avoids repeated wrapping work.
7. Best Practices
Practice 1: Erase at API boundaries, not everywhere
Type erasure is most valuable when converting a complex implementation into a simpler public interface. If you erase too early, you may lose static type information that your own code could still use efficiently.
struct ReportService {
private let source: AnyDataSource<Int>
init<S: DataSource>(source: S) where S.Item == Int {
self.source = AnyDataSource(source)
}
}Keeping the erasure near the boundary makes the rest of your code easier to reason about.
Practice 2: Preserve only the behavior the caller needs
A good erased type should be small and intentional. Expose methods that make sense for the abstraction, not every method of the wrapped type.
struct AnyReadableSequence<Element> {
private let _makeIterator: () -> AnyIterator<Element>
init<S: Sequence>(_ sequence: S) where S.Element == Element {
self._makeIterator = { AnyIterator(sequence.makeIterator()) }
}
}That keeps the abstraction understandable and reduces accidental misuse.
Practice 3: Prefer built-in erased types when they already fit
Swift provides several type-erased standard library types. Use them when they match your need instead of inventing a custom wrapper.
let numbers: AnySequence<Int> = AnySequence([1, 2, 3])
let anyValue: AnyHashable = "hello"Using the standard library version reduces custom code and gives you behavior that other Swift developers already recognize.
8. Limitations and Edge Cases
- Type erasure often adds a small runtime cost because calls go through forwarding closures or boxed storage.
- You lose some compile-time information, so some optimizations and specialized operations are no longer available.
- Erased wrappers cannot magically recover associated types the caller does not know.
- When a protocol requires Self-constrained behavior, only the exposed subset can be forwarded safely.
- Some APIs are better modeled with generics or opaque return types instead of erasure.
- Using any existential and using a custom erased wrapper are not the same thing: the wrapper usually gives you a more focused public API.
One common surprise is that a type-erased wrapper can make code easier to use but harder to inspect in a debugger because the original concrete type is hidden behind abstraction.
9. Practical Mini Project
Let’s build a small notification system that can store different message providers behind one property. The goal is to keep the consumer simple while allowing multiple implementations.
protocol MessageProvider {
func message() -> String
}
struct GreetingProvider: MessageProvider {
func message() -> String {
"Hello!"
}
}
struct ReminderProvider: MessageProvider {
func message() -> String {
"Don't forget to save your work."
}
}
struct AnyMessageProvider {
private let _message: () -> String
init<P: MessageProvider>(_ provider: P) {
self._message = provider.message
}
func message() -> String {
_message()
}
}
struct NotificationCenter {
private let provider: AnyMessageProvider
init<P: MessageProvider>(provider: P) {
self.provider = AnyMessageProvider(provider)
}
func show() {
print(provider.message())
}
}
let centerA = NotificationCenter(provider: GreetingProvider())
let centerB = NotificationCenter(provider: ReminderProvider())
centerA.show()
centerB.show()This mini project shows the main benefit of type erasure: the consumer holds one concrete storage type, while the implementation remains replaceable.
10. Key Points
- Type erasure hides the concrete type while preserving a useful interface.
- It is a practical solution for protocols with associated types or Self requirements.
- Swift standard library types such as AnySequence and AnyHashable are built-in examples.
- Use it to simplify APIs, not to remove every trace of type information everywhere.
- Keep the wrapper focused on the behavior callers actually need.
11. Practice Exercise
- Write a protocol called Storage with one method that returns a String.
- Create two concrete types that conform to it.
- Build a type-erased wrapper named AnyStorage.
- Use the wrapper inside a struct that stores one storage instance and prints its value.
- Test the struct with both concrete storage types.
Expected output: The program should print the two stored values, one after the other, without exposing the concrete storage types to the consumer.
Hint: Capture the protocol method in a closure inside the wrapper, then forward the call through that closure.
protocol Storage {
func value() -> String
}
struct FirstStorage: Storage {
func value() -> String { "One" }
}
struct SecondStorage: Storage {
func value() -> String { "Two" }
}
struct AnyStorage {
private let _value: () -> String
init<S: Storage>(_ storage: S) {
self._value = storage.value
}
func value() -> String {
_value()
}
}
struct StoragePrinter {
let storage: AnyStorage
init<S: Storage>(storage: S) {
self.storage = AnyStorage(storage)
}
func printValue() {
print(storage.value())
}
}
let printer1 = StoragePrinter(storage: FirstStorage())
let printer2 = StoragePrinter(storage: SecondStorage())
printer1.printValue()
printer2.printValue()12. Final Summary
Swift type erasure is a powerful abstraction technique for turning concrete, sometimes complicated types into a simpler common interface. It is especially helpful when protocols have associated types, when you need to store different implementations in one property, or when you want to keep your public API flexible and clean.
The most important idea is that type erasure does not remove type safety; it relocates the complexity into a wrapper so the rest of your code can use a consistent type. When you build or choose an erased type, keep the API focused, create the wrapper at the right boundary, and prefer Swift’s built-in erased types when they already solve the problem.
If you want to go deeper, next study Swift generics and opaque return types, because understanding those alternatives will help you decide when type erasure is the right tool.