Blog
Using "Protocol" Witnesses in Swift


(Note: This post uses Swift 5.3)

I’ve been going through PointFree’s video backlog lately and have been learning a ton. The concept that’s stuck in my mind the most, though, is that of using a generic struct as a replacement for protocols. If you’ve ever been frustrated working with protocols, running into issues with associatedTypes and Selfs and type erasure, then you may have your mind blown a little bit.

The Scenario

Let’s say we’re working on a networking class for a social media app. Our backend uses GraphQL, but using the Apollo framework would be overkill for our purposes, so we’re handling all of the network code ourselves. (We’ll also be using the Combine framework’s publishers rather than traditional completion closures; I’ll be writing a separate post about that in the near future.)

Our main Person model looks like this:

struct Person: Codable {
    let id: UUID?
    var name: String
    let birthdate: Date
    let friends: [Person]

    init(id: UUID? = nil, name: String, birthdate: Date, friends: [Person] = []) {
        self.id = id
        self.name = name
        self.birthdate = birthdate
        self.friends = friends
    }
}

…and we want the main query/mutation method signature to look something like this:

func query<Input, Output>(_ input: Input) -> AnyPublisher<Input, QueryError>

…where QueryError is just a simple Error-conforming struct.

How can we make this method as simple to use as possible, where the callsite can look as simple as this?

query(.addPerson(named: "Jon Bash", withBirthdate: myBirthdate))
    .sink(/* handle response here */)
    .store(in: &cancellables)

Enum?

One thing that might be nice is to have an enum that stores all of our queries, like this:

enum QueryInput: Encodable {
    case addPerson(Person)
    case fetchFriends(UUID)

    var queryString: String {
        switch self {
            // insert query string for each case here
        }
    }
}

(GraphQL queries and mutations are formatted in a very specific way; the way we handled it recently in our work on the Eco-Soap Bank app was to have these static strings that would be sent as part of the request. Usually code for these would be generated by the Apollo framework, but that sort of code generation and third-party library usage isn’t really necessary, and this gives us finer-grained control of how our app works.)

But there’s a problem: how will the query method know what the output is? Maybe we can add a computed property to the enum.

extension QueryInput {
    var outputType: ??? {
        switch self {
        case .addPerson: return UUID.self
        case .fetchFriends: return [Person].self
        }
    }
}

…but what is the actual type of outputType? Well, it could be just about any type… so let’s try using Any.Type.

extension QueryInput {
    var outputType: Any.Type { /*...*/ }
}

This will compile, but actually, it needs to be Decodable, so let’s switch it to…

   var outputType: Decodable.Type

…but when we go to adjust our query method, we run into more problems…

func query(_ input: QueryInput) -> AnyPublisher<QueryInput.outputType, QueryError>
// Error: "Property 'outputType' is not a member type of 'QueryInput'"

The compiler can’t tell what the output should be, and there’s isn’t any way (that I’m aware of) for us to tell the compiler to look at this computed property to determine the type. We could make the enum generic and just pass in what we’re expecting every time, but that would complicate the callsite more than we’d like, and it would be too easy to make programmer errors (for example, I could pass in a fetchFriends query and tell it I’m expecting a [Double: [UInt8]] back).

Protocol?

Perhaps we can make a protocol that our input can conform to; something like this:

protocol QueryInput: Encodable {
    associatedtype Output: Decodable

    static var queryString: String { get }
}

…and we can extend our Person model as such:

extension Person: QueryInput {
    typealias Output = UUID

    static var queryString: String { "<insert GraphQL query string here>" }
}

…as well as UUID, which will be the input for fetching friends.

extension UUID: QueryInput {
    typealias Output = [Person]

    static var queryString: String { "<insert GraphQL query string here>" }
}

Now we can adjust our query method, and it looks like it just might work!

func query<Input: QueryInput>(_ input: Input) -> AnyPublisher<Input.Output, QueryError>

However, there’s still a few problems. Here’s what it looks like when we call this method for fetching friends:

query(personID)
    .sink // ...etc...

It’s not easy to tell at a glance that what we’re doing is fetching friends. Even worse, what if there was also an Event model that had an id property of type UUID, and we wanted to fetch some info about that? We’ve already conformed UUID to the protocol for use with fetching friends, and we can’t conform to the protocol twice.

Namespaced Wrapper Structs?

One solution might be to make a wrapper struct for each possible input and output, each of which would conform to QueryInput. We could even “namespace” them in an empty enum (this is how the Combine framework namespaces some of its publishers):

enum QueryInputs {
    struct AddPerson: QueryInput {
        typealias Output = UUID

        let name: String
        let birthdate: Date

        static let queryString = "<insert query string here>"
    }

    struct FetchFriends: QueryInput {
        typealias Output = [Person]

        let id: UUID

        static let queryString = "<insert query string here>"
    }
}

Now, the callsite looks like this:

query(QueryInputs.AddPerson(name: "Jon Bash", birthdate: myBirthdate))
   .sink( // etc...

I think this will work!

…But it could be better. It’s just a bit longer than our “ideal” that we started out with. Because QueryInputs (plural) isn’t actually the type of this input, we can’t remove anything there.

Even worse, let’s say we want to make an array of some different queries that fetch a Person. When we go to initialize this array, we immediately run into a problem…

let queries: [QueryInput] = []
// Error: Protocol 'QueryInput' can only be used as a generic constraint because it has Self or associated type requirements

That dreaded Self/associated type error. We can’t add a generic to it either; a protocol with an associated type is technically not a generic type.

This is, as far as I’ve been able to tell, a limitation of the language, and it’s why complicated type erasure exists. I’ll be writing a separate post later about type erasure works, but for our purposes, there’s a much simpler, more elegant, and, in my opinion, more interesting solution.

“Protocol” Witnesses

We can accomplish what we want using one simple generic struct. And although the PointFree folks like to call this a “protocol witness,” despite the name, we don’t need any protocol at all.

struct QueryInput<Output: Decodable>: Encodable {
    var input: Encodable
    var queryString: String

    init(_ input: Encodable, queryString: String) {
        self.input = input
        self.queryString = queryString
    }

    func encode(to encoder: Encoder) throws {
        try input.encode(to: encoder)
    }
}

Our input could be anything that can be encoded; what matters is the output type. This is somewhat similar to how a traditional type-erasure would work, but with much, much less work.

One thing you might notice is that we see the output type in the signature there… but it’s not used anywhere (yet). This is a “phantom type” (which you can read more about at Hacking with Swift). It’s not stored anywhere in an instance of the type, but it can be used when we pass it in to our query method.

So how do we construct one of these? It would be a pain in the butt to have to give it the query string every time we run a query. Luckily, we can take advantage of the language to add some convenience “initializers”…!

extension QueryInput where Output == UUID {
    static func addPerson(_ person: Person) -> QueryInput<UUID> {
        QueryInput<UUID>(person, queryString: "<insert query here>")
    }
}

And we can make a few quick changes to make it even simpler, especially at the callsite:

extension QueryInput where Output == UUID {
    static func addPerson(
        named name: String,
        withBirthdate birthdate: Date
    ) -> Self {
        Self(
            Person(name: name, birthdate: birthdate),
            queryString: "<insert query here>"
        )
    }
}

We can replace QueryInput<UUID> with Self, since the compiler can infer these specifics from the where clause in the extension signature. This will make it easier as we add more static convenience methods for our different queries.

At first glance, the rest of it may look more complicated, but compare the two callsites for these implementations:

query(
    .addPerson(
        Person(
            name: "Jon Bash",
            birthdate: Date()
        )
    )
)

query(
    .addPerson(
        named: "Jon Bash",
        withBirthdate: Date()
    )
)

The first has an additional layer of nesting (which the second avoids), and the second lets us customize our parameter names to make it read almost like a sentence (“query: add person named ‘Jon Bash’ with birthdate Junevember 41nd, 1010220”). Which you prefer might depend on the context; you could even keep both if it made sense!

Also, notice that we’ve finally got the clean syntax for calling our queries that we set out to attain at the start! And we can now have arrays of different queries that expect the same output, just like with traditional type erasure:

extension QueryInput where Output == Person {
    static func fetchPerson(withName name: String) -> Self {
        Self(name, queryString: "<insert query string here>")
    }

    static func fetchOrganizer(forEventID id: UUID) -> Self {
        Self(id, queryString: "<query string here>")
    }
}

var queries: [QueryInput<Person>] = [
    .fetchPerson(withName: "Jon Bash"),
    .fetchOrganizer(forEventID: UUID()),
    .fetchPerson(withName: "Jim Bash")
]

Which gives us some powerful flexibility:

var cancellables = Set<AnyCancellable>()

var people = [Person]()

for q in queries {
    query(q)
        .sink { completion in
            if case .failure(let error) = completion {
                print(error)
            }
        } receiveValue: { person in
            people.append(person)
        }.store(in: &cancellables)
}

…And we could even leverage Combine to make this even cleaner (more on what all this means later…):

queries.publisher
    .flatMap { $0.run() }
    .collect()
    .sink { completion in
        if let e = completion.error { print(e) }
    } receiveValue: { people in
        // handle full array of people
    }.store(in: &cancellables)

At this point, depending on the scenario, there may not even be a reason to have a whole separate networking class; we could run a query directly from this struct, perhaps with a quick rename…

struct Query<Output: Decodable>: Encodable {
    var input: Encodable
    var queryString: String

    init(_ input: Encodable, queryString: String) {
        self.input = input
        self.queryString = queryString
    }

    func encode(to encoder: Encoder) throws {
        try input.encode(to: encoder)
    }

    func run() -> AnyPublisher<Output, QueryError> {
        // do the work of making encoding the data, making the request, running the query, decoding the data, etc
    }
}

Now running a query to fetch a person with a specific name is a simple as:

Query.fetchPerson(withName: "Jon Bash").run()
    .sink( // etc...

We could even pass in separate encoders or decoders for different queries, different URLSessions (or mock replacements), different URLs or endpoints, or even a closure in place of run. All we’d need to do is add a property and use that in the run() method.

Conclusions

To solve our problem of making generic network queries, we tried a non-functional enum, a protocol, a namespaced input struct that conforms to that protocol, and finally landed on a simple struct that allows us to essentially do everything in one spot. This solution also allows for greater testability, of course, which is yet another benefit for which we would usually go to protocols. But we’ve managed to avoid the downsides of protocols, like issues around Self, associated types, and type erasure.

Although it’s not necessarily the most intuitive solution at first glance, it ends up being really simple to write and use once you know how to make it work.


Subscribe to new posts:

RSS