Tim J
Tim J

Reputation: 1271

How can I use Type Erasure with a protocol using associated type

I am working on a project that has a network client that basically follows the below pattern.

protocol EndpointType {
    var baseURL: String { get }
}

enum ProfilesAPI {
    case fetchProfileForUser(id: String)
}

extension ProfilesAPI: EndpointType {
    var baseURL: String {
        return "https://foo.bar"
    }
}

protocol ClientType: class {
    associatedtype T: EndpointType
    func request(_ request: T) -> Void
}

class Client<T: EndpointType>: ClientType {
    func request(_ request: T) -> Void {
        print(request.baseURL)
    }
}

let client = Client<ProfilesAPI>()

client.request(.fetchProfileForUser(id: "123"))

As part of tidying up this project and writing tests I have found the it is not possible to inject a client when conforming to the ClientType protocol.

let client: ClientType = Client<ProfilesAPI>() produces an error:

error: member 'request' cannot be used on value of protocol type 'ClientType'; use a generic constraint instead

I would like to maintain the current pattern ... = Client<ProfilesAPI>()

Is it possible to achieve this using type erasure? I have been reading but am not sure how to make this work.

Upvotes: 1

Views: 257

Answers (1)

Rob Napier
Rob Napier

Reputation: 299325

To your actual question, the type eraser is straight-forward:

final class AnyClient<T: EndpointType>: ClientType {
    let _request: (T) -> Void
    func request(_ request: T) { _request(request) }

    init<Client: ClientType>(_ client: Client) where Client.T == T {
        _request = client.request
    }
}

You'll need one of these _func/func pairs for each requirement in the protocol. You can use it this way:

let client = AnyClient(Client<ProfilesAPI>())

And then you can create a testing harness like:

class RecordingClient<T: EndpointType>: ClientType {
    var requests: [T] = []
    func request(_ request: T) -> Void {
        requests.append(request)
        print("recording: \(request.baseURL)")
    }
}

And use that one instead:

let client = AnyClient(RecordingClient<ProfilesAPI>())

But I don't really recommend this approach if you can avoid it. Type erasers are a headache. Instead, I would look inside of Client, and extract the non-generic part into a ClientEngine protocol that doesn't require T. Then make that swappable when you construct the Client. Then you don't need type erasers, and you don't have to expose an extra protocol to the callers (just EndpointType).

For example, the engine part:

protocol ClientEngine: class {
    func request(_ request: String) -> Void
}

class StandardClientEngine: ClientEngine {
    func request(_ request: String) -> Void {
        print(request)
    }
}

The client that holds an engine. Notice how it uses a default parameter so that callers don't have to change anything.

class Client<T: EndpointType> {
    let engine: ClientEngine
    init(engine: ClientEngine = StandardClientEngine()) { self.engine = engine }

    func request(_ request: T) -> Void {
        engine.request(request.baseURL)
    }
}

let client = Client<ProfilesAPI>()

And again, a recording version:

class RecordingClientEngine: ClientEngine {
    var requests: [String] = []
    func request(_ request: String) -> Void {
        requests.append(request)
        print("recording: \(request)")
    }
}

let client = Client<ProfilesAPI>(engine: RecordingClientEngine())

Upvotes: 2

Related Questions