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.

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:

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

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: DataSource

Fix: 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

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

11. Practice Exercise

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.