Type Erasure in Swift – Simplify using Protocols with Associated Types; any&some vs manual(custom) approaches

Type Erasure in Swift – Simplify using Protocols with Associated Types; any&some vs manual(custom) approaches

The Problem: Why Should You Care About Type Erasure?

Imagine you’re building a flexible system in Swift, and you decide to use a protocol with an associated type (PAT). After all, relying on abstractions rather than concrete implementations is a cornerstone of good software design—shoutout to SOLID principles and @Uncle Bob’s best practices! For example, you define a protocol like this:

protocol InterfaceWithAssociatedType {
    associatedtype OurType
    func doSomething(with value: OurType)
}

This approach allows you to design a system that’s modular, testable, and aligned with the Dependency Inversion Principle (the “D” in SOLID): depending on abstractions rather than concrete details. But here’s the thing—protocols with associated types have their own quirks. Specifically, you’ll quickly realise they don’t play nicely with variables or function parameters without some extra effort. So, how do you bridge the gap between abstraction and implementation? Let’s dive in!

For example, suppose you want to store an instance of a type conforming to this protocol in a variable or pass it as a function parameter. You’ll immediately run into a problem:

struct StringHandler: InterfaceWithAssociatedType {
    func doSomething(with value: String) {
        print("Handling string: \(value)")
    }
}

let handler: InterfaceWithAssociatedType = StringHandler() 
handler.doSomething(with: "Hello, World!") // Member 'doSomething' cannot be used on value of type 'any InterfaceWithAssociatedType'; consider using a generic constraint instead

The compiler throws an error because the associated type (OurType) is unresolved. This limitation makes protocols with associated types challenging to use directly.

So, how do you work around this? Enter type erasure - a technique that hides the associated type, allowing you to use the protocol as a type. In this article, we’ll explore two main approaches to type erasure: any and some and manual (custom) type erasure.

Approach 1: Using any and some

Swift provides two powerful tools for working with protocols with associated types: any and some. These keywords allow you to simplify your code while maintaining some level of type safety.

What Are any and some?

  • any (Introduced in Swift 5.6) The any keyword allows you to use protocols with associated types as existential types. It essentially tells the compiler, “I don’t care about the specific type; just let me work with any type that conforms to the protocol.”
let stringHandler: any InterfaceWithAssociatedType = StringHandler()

func process(handler: any InterfaceWithAssociatedType) {
    // Note: You cannot call `doSomething` directly because `OurType` is unknown.
    // You would need to cast or use a wrapper to resolve the type.

    /* e.g.:  casting 
    (handler as? StringHandler)?.doSomething(with: "Hello world")
    */
}

While any solves the immediate issue of storing or passing the protocol, it introduces runtime overhead and limits functionality because the associated type remains unresolved.

Read more about any in Swift 5.6 proposal

  • some (Introduced in Swift 5.1) The some keyword allows you to return a specific, opaque type that conforms to a protocol. This ensures type safety at compile time while hiding the concrete type from the caller. You’re likely already familiar with this concept if you’ve worked with SwiftUI’s View protocol.

Let’s take a look at these code snippets:

func createHandler() -> some InterfaceWithAssociatedType {
     StringHandler()
}

let handler = createHandler()
handler.doSomething(with: "Hello World") // ERROR: Cannot convert value of type 'String' to expected argument type '(some InterfaceWithAssociatedType).OurType'

The issue occurs because some hides the associated type (OurType), making it impossible for the compiler to verify that “Hello World” matches the expected type. No way to che that! To fix it, you can either use generics or manual type erasure (we’ll be checking that in upcoming paragraph) to explicitly handle the associated type. Manual type erasure is often the most practical solution in real-world scenarios. Or we can modify our interface to be able to provide the type as associated values. E.g.:

protocol InterfaceWithAssociatedType<OurType> { // <- `<OurType>`
    associatedtype OurType
    
    func doSomething(with value: OurType)
}

// without using a generics here we encountered a slight duplication : `...<String>`
func process(handler: some InterfaceWithAssociatedTypeNew<String>) {
    handler.doSomething(with: "Hello world")
}

struct StringHandlerNew<TypeNew>: InterfaceWithAssociatedTypeNew {
    func doSomething(with value: TypeNew) {
        print("Handling new string: \(value)")
    }
}

let handler = StringHandlerNew<String>()
process(handler: handler)
// or, simply:
handler.doSomething(with: "Direct call")

btw, our function

func process(handler: some InterfaceWithAssociatedType) {
   ...
}

has the same signature as:

func process<T: InterfaceWithAssociatedType>(handler: T) {
  ...
}

…this seems to be one of the primary reasons why “some” was introduced: simplifying the signature.

Read more about some in Swift 5.1 proposal

Approach 2 (why did we all come here): Manual (Custom) Type Erasure

If you need full control over your types and want to avoid the limitations of any and some, manual type erasure is the way to go. This approach involves creating a wrapper struct or class that conforms to the protocol and hides the associated type.

struct AnyInterfaceWithAssociatedType<T>: InterfaceWithAssociatedType {
    private let _doSomething: (T) -> Void

    init<H: InterfaceWithAssociatedType>(_ handler: H) where H.OurType == T {
        _doSomething = handler.doSomething
    }

    func doSomething(with value: T) {
        _doSomething(value)
    }
}

Now, you can use the type eraser to store or pass handlers:


let stringHandlerManualEraser = AnyInterfaceWithAssociatedType(StringHandler())

func process<T>(handler: AnyInterfaceWithAssociatedType<T>, 
                value: T) {
    handler.doSomething(with: value)
}

process(handler: stringHandlerManualEraser, 
        value: "Hello World, from manual eraser!")

This approach provides full type safety and flexibility but requires additional boilerplate code.

If you suddenly noticed that the manual type erasure approach looks a lot like the Decorator pattern — well, you’re absolutely right!

In essence, what we’re doing here is wrapping the original type in a struct or class that “decorates” it by conforming to the protocol and delegating all the work back to the underlying implementation. It’s like putting a fancy coat on your concrete type to hide its complexity while still letting it do its job. This is exactly what the Decorator pattern is all about: adding a layer of abstraction without changing the core functionality.

So yeah, manual type erasure isn’t just a Swift trick—it’s also a nod to classic design patterns. Cool, right? 🎩✨

Comparison of Approaches

Approach Pros Cons
any & some - Simple and easy to implement
- Minimal boilerplate
- Quick solution for many cases
- Runtime overhead with any
- Limited to return types with some
- Less control over associated types
Manual (Custom) - Full type safety
- Maximum flexibility
- No runtime overhead
- Works for variables, parameters, and return types
- Requires additional boilerplate code
- More complex to implement and maintain
- Not as “out-of-the-box” as any or some

Conclusion

Protocols with associated types are a powerful feature in Swift, but they come with challenges. Type erasure provides a way to work around these limitations by hiding the associated type.

Use any and some for simplicity and quick solutions. Use manual type erasure when you need full control and flexibility. By understanding these techniques, you can choose the right approach for your specific use case and avoid common pitfalls when working with PATs.

P.S. In many cases, you’ll realise that you probably didn’t intend to use any and can instead make some or a generic work for your needs. According to the Swift team, we should always aim to avoid using any whenever possible. This is because any relies on runtime checks to determine the underlying type, which introduces overhead and potential inefficiencies. On the other hand, when using some, the type is resolved at compile time, making it more efficient and type-safe.

Last updated on