How to Implement Type Erasure in Swift

Apr 01, 2023#swift#how-to

Type erasure in Swift can be explained as the process of enforcing type constraints only at compile time and discarding the element type information at runtime. It is often used when working with protocols that have associated types or use the Self metatype, which makes them impossible to reference as stand-alone protocols.

The goal is to hide the specific type behind a more abstract type that can be used in containers or functions, simplify your code and improve the interoperability of your types, useful for various use cases, such as:

  • Creating heterogeneous collections of values.
  • Hiding implementation details of a protocol or a type across API boundaries, such as different modules. When you use type erasure this way, you can change the underlying implementation over time without affecting existing clients.
  • Working with protocols that have Self requirements in contexts where the concrete type is not known or relevant.
  • Interoperating with Objective-C code that does not support generics or associated types.

Some examples of built-in type erased types in Swift, often starting with Any, are: AnyHashable, AnySequence, AnyPublisher, AnyView, or AnyCancellable.

Using a wrapper type is a common way to implement type erasure by creating a generic type that conforms to the protocol and wraps an instance of any type that also conforms to the protocol.

Here’re an example to create type erased AnyShape:

// The protocol with an associated type
protocol Shape {
    associatedtype Area
    
    var name: String { get }
    var area: Area { get }
}

// A struct that conforms to the Shape protocol
struct Square: Shape {
    var side: Double
    
    var name: String { "Square" }
    var area: Double { side * side }
}

// A struct that conforms to the Shape protocol
struct Circle: Shape {
    var radius: Double
    
    var name: String { "Circle" }
    var area: Double { .pi * radius * radius }
}

// The type erased wrapper
struct AnyShape<Area>: Shape {
    private let nameClosure: () -> String
    private let areaClosure: () -> Area
    
    init<T: Shape>(_ shape: T) where T.Area == Area {
        nameClosure = { shape.name }
        areaClosure = { shape.area }
    }
    
    var name: String {
        nameClosure()
    }
    
    var area: Area {
        areaClosure()
    }
}

// This will compile
let shapes: [AnyShape<Double>] = [
    AnyShape(Square(side: 10)),
    AnyShape(Circle(radius: 5))
]

Types that are created by using custom type-erased wrappers can be rewritten using the any keyword since Swift 5.7 in most cases (but not all).

// This will compile
let shapes: [any Shape] = [
    Square(side: 10),
    Circle(radius: 5)
]

However, there are some cases where you cannot use the any keyword to replace custom type-erased types if they add additional functionality or requirements that are not part of the protocol it conforms to. For example, an AnyCancellable instance automatically calls cancel() when deinitialized.