Protocol array extensions not working
In a project that I’m currently working on, I want to redesign how we extend protocol-based domain models. However, what first looked east turned into a Swift nightmare, with problems that I’m still struggling with.
Disclaimer
In this post, I will intentionally use simple models. If you think that something makes no sense (e.g. “why the hell are you using x instead of y”), keep in mind that the code you’ll see is a work of fiction.
Protocol extensions
In Swift, protocol extensions is a nice tool to provide protocol implementations with a bunch of logic that makes use of the protocol specification. This reduces the need for duplicate code, base classes etc. by using the protocol contract to provide calculated properties, additional functionality etc.
For instance, consider a Person
protocol that requires two properties: firstName
and lastName
.
protocol Person {
var firstName: String { get }
var lastName: String { get }
}
Instead of requiring all implementations to implement fullName
, we can add a
calculated property as a extension to the protocol instead, making use of the two
properties that it requires:
extension Person {
var fullName: String {
return "\(firstName) \(lastName)"
}
}
This approach is convenient in many cases. Just make sure to not use it for functionality that should be impemented by each implementation.
Protocol collection extensions
Now, let’s look at my struggle - extending collections where the elements are of a certain protocol.
Let’s extend the Person
protocol a little. If we consider that a person should
be able to have friends (seems nice), we could add a friends
property to the
protocol:
protocol Person {
var firstName: String { get }
var lastName: String { get }
var friends: [Person] { get }
}
If we now would like to be able to search for a person’s friends, we could
filter
on the fullName
property to find all friends that match a certain query:
let matchingFriends = person.friends.filter { $0.fullName.contains(query) }
However, if we do this in many places, we will duplicate a piece of logic that I think should be a reusable function, since it defines a standard way to filter a collection of persons.
If we were to go down the domain driven rabbit hole and talk services and how to do this “correctly”, let’s just keep it simple and discuss how we could solve it in the easiest possible way.
One way could be to define this as an additional extension to Person
, as such:
extension Person {
func friends(matching query: String) -> [Person] {
return friends.filter { $0.fullName.contains(query) }
}
}
You could then use this extensions like this:
let matchingFriends = person.friends(matching: query)
In my opinion, this is much more readable. You can use the friends
property to
get all friends and this extension to get a filtered collection. Still, I really
don’t like this approach for a couple of reasons.
One reason is that this filtering only applies when searching for a person’s
friends, while in fact it could apply to all Person
collections. A better approach
would be to extend all Person
collections instead.
Extending Person
collections
To repeat, a big drawback with extending Person
with a friends(matching:)
function is that it can only be used to filter friends, while it could apply to
all Person
collections.
Let’s refactor the extension to be a collection extension instead:
extension Collection where Element: Person {
func matching(_ query: String) -> [Person] {
return filter { $0.fullName.contains(query) }
}
}
That’s better! You can now use this extensions for every person collection you may stumble upon:
let matchingFriends = person.friends.matching("peter")
…or can you? Turns out…you can’t. Since Person
is a protocol and not a concrete
type, the code above won’t work! If you try it, it will fail with this error:
Using 'Person' as a concrete type conforming to protocol 'Person' is not supported
This doesn’t happen for collections that contain types that implement Person
, for
instance:
struct PersonStruct: Person {
var firstName: String
var lastName: String
}
let persons = [PersonStruct(firstName: "sarah", lastName: "huckabee")]
let matches = persons.matching("ah huck") // Great success!
However, if you cast persons
to [Person]
, the error arises once more:
let persons: [Person] = [PersonStruct(firstName: "sarah", lastName: "huckabee")]
let matches = persons.matching("ah huck") // Great success!
Conclusion
This was an unexpected and unfortunate discovery, since I based my entire domain model on protocols. However, it led me to evaluate this architecture, where I eventually came to the conclusion that protocols are not good for models. Instead, I now use structs for models and protocols for services.
However, I think that Swift should improve its extensions so the code above works for protocols as well.
Discussions & More
Please share any ideas, feedback or comments you may have in the Disqus section below, or by replying on Twitter or Mastodon..
If you found this text interesting, make sure to follow me on Twitter and Mastodon for more content like this, and to be notified when new content is published.
If you like & want to support my work, please consider sponsoring me on GitHub Sponsors.