Swift’s Hidden Gem: Type Erasures in iOS

Ferhat Toker
LCW Digital
Published in
7 min readDec 30, 2023

Swift’s type system is, for the most part, outstanding. Its stringent constraints and robust generics enable developers to articulate complex concepts with remarkable safety. This is largely due to the Swift compiler’s capability to identify and flag any type-related inconsistencies in your program. Particularly noteworthy is the concept of type erasure, which adds another layer of sophistication.

The Problem: Constraints of Swift’s Type System

Swift’s type system, known for its strong, compile-time type checking, brings with it certain limitations. This becomes particularly noticeable when using protocols with associated types and generics. These elements cannot be used as standalone types or in mixed-type collections, which can be a significant limitation in certain programming situations. For instance, if you want to store different objects that conform to a protocol with an associated type in one collection, Swift’s strict type safety rules do not allow this directly.

The Solution: Type Erasure

Type erasure in Swift allows for more flexibility in managing types, especially when dealing with generic protocols. By abstracting away the specific type details, it ensures that the code remains clean and more adaptable to changing requirements. This feature not only enhances code reusability but also maintains the integrity of Swift’s strong type system, making it a powerful tool for developers to leverage in their projects.

Less talking, let’s coding…

Let’s consider a protocol for a data source, which is a common pattern in iOS development:

protocol DataSource {
associatedtype ItemType
func item(at index: Int) -> ItemType
}

Now, imagine you’re building an application that needs to manage multiple data sources. You might want to have an array of these data sources. However, you can’t simply create an array of DataSource because each implementation of DataSource might have a different ItemType.

Let’s say you have two data sources:

class ArrayDataSource: DataSource {
typealias ItemType = Int
func item(at index: Int) -> Int {
// returns an integer
}
}

class StringDataSource: DataSource {
typealias ItemType = String
func item(at index: Int) -> String {
// returns a string
}
}

You cannot create an array like this:

var dataSources: [DataSource] = [ArrayDataSource(), StringDataSource()] 
// This causes a compiler error.

Protocol DataSource can only be used as a generic constraint because it has Self or associated type requirements.

This error indicates that DataSource can't be used as a standalone type in an array or any other collection. It's because DataSource itself isn't a complete type due to the its associated type ItemType. It can only be used as a generic constraint, where its associated type can be inferred from the context.

To resolve this, we use type erasure to create a concrete wrapper that provides specific type information to the compiler.

struct AnyDataSource<Item>: DataSource {
private let _itemAt: (Int) -> Item

init<D: DataSource>(_ dataSource: D) where D.ItemType == Item {
_itemAt = dataSource.item
}

func item(at index: Int) -> Item {
return _itemAt(index)
}
}

Now, you can create a uniform array of AnyDataSource with a specified Item type:

var anyDataSources: [AnyDataSource<Any>] = []

anyDataSources.append(AnyDataSource(ArrayDataSource()))
anyDataSources.append(AnyDataSource(StringDataSource()))

Here, AnyDataSource<Any> is a concrete type. The specific ItemType for each DataSource is abstracted away, but from the compiler's perspective, the type of each element in the array is now concrete and known (AnyDataSource<Any>), solving the problem of insufficient type information.

Use Cases in Apple Frameworks

Type erasure is not just a theoretical concept in Swift; it has practical applications in the standard library and various frameworks provided by Apple. Notable examples include AnyCancellable, AnyHashable, AnyIterator, and others. These are instances where Apple uses type erasure to provide flexibility and functionality in Swift. Let’s delve into each of these to understand their use cases.

AnyCancellable

AnyCancellable is a part of the Combine framework, introduced by Apple for handling declarative Swift APIs for processing values over time. AnyCancellable is a type-erased cancellable instance that can be used to cancel a subscription.

Use Case: When you subscribe to a publisher in Combine, you receive a Cancellable object. To manage this subscription, you often store this Cancellable in a collection. However, since subscriptions can have different types, storing them directly would be difficult due to Swift's strict typing. AnyCancellable solves this by wrapping any Cancellable into a type-erased object, allowing you to manage heterogeneous cancellable objects uniformly.

AnyHashable

AnyHashable is a type-erased wrapper around any hashable type.

Use Case: Imagine you have a mix of different hashable types (Int, String, etc.) and you want to store them in the same collection, or use them as keys in a dictionary. Normally, Swift's type system would not allow this due to differing types. AnyHashable wraps these different hashable types into a single type-erased form, allowing them to be used interchangeably in collections or as dictionary keys.

AnyIterator

AnyIterator is a type-erased iterator of any iterator type.

Use Case:AnyIterator allows you to hide the underlying type of an iterator. This is particularly useful when you want to return an iterator from a function but don’t want to expose its concrete type. It enables the creation of generic and reusable code, as you can work with various iterators without knowing their specific types.

Advantages of Type Erasure

  • Flexibility with Protocols: Type erasure allows for the use of protocols with associated types in a more flexible way. It enables you to use these protocols as parameters, return types, or in collections where the specific type isn’t known at compile time.
  • Code Reusability: It promotes code reusability. You can write generic code that works with any type conforming to a protocol, without worrying about the specific associated types.
  • Enhanced Abstraction: Type erasure helps in abstracting implementation details. It allows you to expose only the functionality you want to reveal, hiding the underlying types and complexities.
  • Improved API Design: For library or framework developers, type erasure can lead to cleaner and more user-friendly APIs. Users of the API don’t need to worry about complex type hierarchies or generics.
  • Compatibility with Heterogeneous Types: It enables collections or arrays to store elements of different types, provided they conform to the same protocol, enhancing the ability to work with heterogeneous data types.

Disadvantages of Type Erasure

  • Complexity: Implementing type erasure can add complexity to your code. It often requires additional types and boilerplate code, which can be difficult to understand and maintain.
  • Performance Overheads: Type-erased wrappers can introduce performance overheads. They often involve indirect function calls and additional memory allocations, which can impact performance, especially in critical code paths.
  • Loss of Type Information: While abstraction is an advantage, it can also be a downside. You lose specific type information, which can lead to more type casting and checks at runtime, potentially introducing runtime errors.
  • Limited Compiler Assistance: The abstraction of type information means that you get less compile-time checking and assistance from the compiler. Errors that might have been caught at compile time could now surface at runtime.
  • Potential for Misuse: If not used judiciously, type erasure can lead to overly abstracted and complex code architectures, where simpler solutions might suffice.

When to Use Type Erasure: A Guided Approach

Here are some situations where using type erasure is advisable:

  • Protocols with Associated Types in Collections: If you have a protocol with an associated type and you need to store instances conforming to this protocol in a collection, type erasure is necessary. For example, when you have different objects conforming to the same protocol and you want to store them in an array or a dictionary, type erasure lets you do this while maintaining type safety.
  • Protocols in Generic Constraints: When you’re using protocols as generic constraints and these protocols have associated types or self-referential requirements, type erasure can help in abstracting these details to make the protocols usable in a generic context.
  • Heterogeneous Types Handling: In cases where you need to work with heterogeneous types (different types conforming to the same protocol) in a uniform way, type erasure allows you to treat these different types as a single type.
  • API Design and Abstraction: If you’re designing an API or a framework and you want to expose functionality without exposing the underlying types or implementation details, type erasure can be a valuable tool. It allows API consumers to use the functionality without worrying about the specific types involved.
  • Reducing Boilerplate for Complex Type Hierarchies: When you have complex type hierarchies and want to simplify interactions between different parts of your code, type erasure can reduce the need for boilerplate code and make your codebase cleaner and more maintainable.

When to Avoid Type Erasure

However, there are also situations where type erasure might not be the best approach:

  • Simple Protocols without Associated Types: If your protocol doesn’t have an associated type or self-referential requirements, you probably don’t need type erasure.
  • Performance-Critical Code: Since type erasure can introduce additional layers of indirection and potentially impact performance, it may not be suitable for performance-critical sections of your code.
  • When Explicit Type Information is Required: If having explicit type information is crucial for your application logic or for maintaining type safety, avoiding type erasure might be a better option.
  • Increased Complexity without Clear Benefit: If introducing type erasure makes your code significantly more complex without providing a clear benefit, it’s worth considering simpler alternatives.

Conclusion: Balancing Power and Complexity

Type erasure in Swift is a sophisticated programming technique that plays a crucial role in overcoming some of the limitations inherent in a strongly typed language. It provides a way to abstract away from specific type details, particularly when dealing with protocols that have associated types or self-referential requirements.

While it offers significant advantages in creating more generic, versatile, and cleaner APIs, it also introduces added complexity and potential performance overheads. Therefore, its use should be carefully considered. It is most advantageous in scenarios requiring the storage and manipulation of heterogeneous types under a single protocol, in API design for hiding implementation details, and in reducing boilerplate code associated with complex type hierarchies.

However, type erasure is not a one-size-fits-all solution. It is less beneficial, and possibly even counterproductive, in situations where protocols are simple and do not have associated types, where performance is a critical concern, or where the explicit type information is essential for maintaining clarity and type safety.

Let’s Coding!⚡️

References

--

--