Type Erasure

Type Erasure

Protocol With Associated Type

A protocol with associated type (PAT) is like a generic protocol, but the generic type is one of the protocol’s requirements. It gives you a placeholder name to use in the rest of the protocol definition.

protocol Request {
  associatedtype Response
  associatedtype Error: Swift.Error
  func perform(then handler: @escaping (Result<Response, Error>) -> Void)
}

This protocol enables you to handle data requests like network calls, database queries, and cache fetches with a single, unified interface. To implement Request, a type must provide concrete types for Response and Error. You can do this explicitly, using typealias.

struct NetworkRequest: Request {
  typealias Response = HTTPURLResponse
  typealias Error = URLError
  func perform(then handler: @escaping (Result<Response, Error>) -> Void) { }
}

Or you can use the concrete type when implementing one of the PAT’s methods and let Swift figure it out.

struct NetworkRequest: Request {
  func perform(then handler: @escaping (Result<HTTPURLResponse, URLError>) -> Void) {
    // ...
  }
}

Here, you use HTTPURLResponse and URLError, where the PAT method expects Response and Error, and Swift infers that HTTPURLResponse and URLError are the associated types.

Type Erasure

When you want to use a PAT (or any generic type) as a concrete type, you must employ type erasure: Convert a PAT into a concrete type by removing type information. Type erasure hides a generic type in a non-generic type that’s easier to use in your code. You’ll often want to use a PAT as a type, like this:

func fetchAll(_ requests: [Request]) { ... }

Your code creates instances of different concrete types that implement the Request PAT, and you want to store all of them in a collection. Here’s the problem: Like generic types, PATs are not concrete types. requests: [Request] produces an error message with a suggested fix:

Use of protocol 'Request' as a type must be written 'any Request'
Replace 'Request' with 'any Request'

To appreciate this suggestion, you must know a bit of the history behind it. Before Swift 5.7, the error message would’ve been:

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

The actual type of an associated type can’t be determined at compile time, so using Request this way isn’t allowed. You could rewrite your code to use Request as a generic constraint:

func fetchAll<R: Request>(_ requests: [R]) { ... }

But this just pushes the problem further downstream. And usually, when you want to use a PAT this way, you’re not actually interested in its associated type requirements. Before Swift 5.7, you would’ve written an unconstrained “Any” version of your PAT, something like this:

struct AnyRequest<Response, Error: Swift.Error> {
  typealias Handler = (Result<Response, Error>) -> Void
  let perform: (@escaping Handler) -> Void
  let handler: Handler
}

AnyX type erasure isn’t type-safe. It’s also error-prone, often making rampant use of Any and force-type-casting. The Swift 5.7 fix Replace ‘Request’ with ‘any Request’ is a quick, easy, and type-safe form of type erasure. The Swift compiler automatically creates an existential type for you and type-checks your code, just like you’re used to.

However, situations still exist where you’ll need to fall back on manual type erasure, such as when you need additional capabilities. If you want to use PAT objects as dictionary keys, they must be Hashable.

protocol APIRequest {
  var url: URL { get }
  var method: HTTPMethod { get }
  associatedtype Output
  func decode(_ data: Data) throws -> Output
}

class APIRequestCache<Value> {
  private var store: [APIRequest: Value] = [:]
}

If you add : Hashable to protocol APIRequest, then use any APIRequest as the dictionary key, you get the error message “Only concrete types such as structs, enums and classes can conform to protocols”. Puzzled, you think: “I made APIRequest inherit from Hashable. Isn’t that enough for it to conform to Hashable?”

This Swift forum post asks the same question, and responses refer the original poster to Protocol Types Cannot Conform to Protocols:

In general, any initializers, static members, and associated types required by a protocol can be used only via conforming concrete types. Although Swift allows a protocol that requires initializers or static members to be used as a type, that type does not and cannot conform to the protocol itself. Currently, even if a protocol P requires no initializers or static members, the existential type P does not conform to P (with exceptions below). This restriction allows library authors to add such requirements (initializers or static members) to an existing protocol without breaking their users’ source code.

OK, concrete type is clear enough, but “existential type”? This is just one of myriad types you need to explore when delving deeper into protocols. The next instruction page tries to set you on your way.

In this case, the solution is to create the required concrete type: a struct, enum, or class:

struct AnyAPIRequest: Hashable {
  let url: URL
  let method: HTTPMethod
}

AnyAPIRequest has the key properties of APIRequest, and these properties are Hashable, so there’s no problem conforming. To store and retrieve APIRequest objects, you convert them to AnyAPIRequest objects:

class APIRequestCache<Value> {
  private var store: [AnyAPIRequest: Value] = [:]

  func response<R: APIRequest>(for request: R) -> Value? where R.Output == Value {
    let erasedAPIRequest = AnyAPIRequest(url: request.url, method: request.method)
    return store[erasedAPIRequest]
  }

  func saveResponse<R: APIRequest>(_ response: Value, for request: R) where R.Output == Value {
    let erasedAPIRequest = AnyAPIRequest(url: request.url, method: request.method)
    store[erasedAPIRequest] = response
  }
}

Type-erasure techniques are something of an art. Here are two articles that can teach you more about them:

Primary Associated Types

Here’s another feature introduced in Swift 5.7: You can declare one or more of your protocol’s associated types as its primary associated types by including them in angle brackets:

protocol Request2<Response> {
    associatedtype Response
    associatedtype Error: Swift.Error
    func perform(then handler: @escaping (Result<Response, Error>) -> Void)
}

This lets you declare generic constraints more concisely. Instead of something like this:

extension Request2 where Response == HTTPURLResponse { ... }

You can write:

extension Request2<HTTPURLResponse> { ... }

And you can use this with the any keyword to declare type-erased, partially constrained stored properties:

let networkRequests: [any Request2<HTTPURLResponse>]

Now that you’ve covered type erasure, you’re going to learn about types in the next lesson.

See forum comments
Download course materials from Github
Previous: Combining Protocols Next: Types