Combine keypaths, associated types, and generics in one
Swift 4.0 introduced new keypath types as part of SE-0161, but it’s fair to say a lot of folks either don’t understand them or don’t yet understand the benefits they can deliver.
Keypaths are designed to allow you to refer to properties without actually invoking them – you hold a reference to the property itself, rather than reading its value. Their use in Swift is still evolving, and in many ways they are influenced by keypath support in Objective-C, but already some clear design patterns are emerging.
I cover a selection of uses for Swift keypaths in my book Swift Design Patterns, but here I want to demonstrate how invaluable they are as a way of letting us reference different types in a natural way.
Try creating these two structs in your playground:
struct Person {
var socialSecurityNumber: String
var name: String
}
struct Book {
var isbn: String
var title: String
}
I’ve purposefully made them completely different so you can see how this pattern works.
Both Person
and Book
have an identifier that is unique: socialSecurityNumber
and isbn
respectively. If we wanted to be able to work with identifiable objects in general, we could try to write a protocol like this:
protocol Identifiable {
var id: String { get set }
}
That would work well enough for Person
and Book
, but it would struggle for anything that didn’t store its identifier as a string – a WebPage
might use a URL
, and a File
might use a UUID
, for example.
Worse, it locks us into using id
as the unique identifier for all our data, which isn’t a descriptive property name – you need to remember that id
is actually a social security number for people and an ISBN for books.
Keypaths can help us solve this problem, allowing us to use them as adapters for very different data types – i.e., allow them to be treated the same even though they aren’t the same. The Gang of Four book has a conceptually similar approach called the adapter pattern: something that allows incompatible data types to be used together by wrapping an interface around them.
What we’re going to do is create an Identifiable
protocol that uses an associated type (a hole in the protocol), then use that as a keypath. What this means is that every conforming type will be asked to provide a keypath that points towards whatever property identifies it uniquely – socialSecurityNumber
and isbn
for our two example structs.
First, add this protocol:
protocol Identifiable {
associatedtype ID
static var idKey: WritableKeyPath<Self, ID> { get }
}
WritableKeyPath
is one of several variants of Swift's keypath types that let us store keypaths for later. In this case we’re saying that the keypath must refer to whichever type conforms to the protocol (Self
) and it will have the same value as whatever is used to fill the ID
hole in our protocol.
Now let’s update both Person
and Book
so they conform to the protocol. This means having adding Identifiable
to their list of conformances, then defining idKey
to point to whichever of their properties is their unique identifier:
struct Person: Identifiable {
static let idKey = \Person.socialSecurityNumber
var socialSecurityNumber: String
var name: String
}
struct Book: Identifiable {
static let idKey = \Book.isbn
var isbn: String
var title: String
}
Swift’s type inference is extremely clever here. When it looks at Person
it will:
socialSecurityNumber
is a String
.idKey
points to Person.socialSecurityNumber
, which is a string.idKey
in Person
with the same property in Identifiable
.WritableKeyPath<Self, ID>
to WritableKeyPath<Self, String>
.associatedtype ID
is a hole that is being filled by a string.I know that Swift code can sometimes take a long time to compile, but you have to admit it’s pretty darn amazing.
What we’ve achieved here is that totally disparate data types – structs that are designed to store properties in whichever way works best for them rather than following some arbitrary names imposed by a protocol – are able to be used together. All we care about is that they conform to Identifiable
: once we know that we also know it has an idKey
keypath that points to where its identifying property is.
Putting all this together we can print the identifier of any Identifiable
type like this:
func printID<T: Identifiable>(thing: T) {
print(thing[keyPath: T.idKey])
}
let taylor = Person(socialSecurityNumber: "555-55-5555", name: "Taylor Swift")
printID(thing: taylor)
Yes, that code leverages generics, keypaths, and associated types all in one, and with surprisingly little code – Swift is an extremely powerful language when you really lean on it. The end result is that we’ve been able to separate our architecture from implementation details – we’ve let types be expressed naturally, then used protocols to overlay an adapter on top to allow those types to be used together.
This was an excerpt from my book Swift Design Patterns – if you'd like to learn more about keypaths, along with delegation, protocols, associative storage, and more, you should check it out!
SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.
Link copied to your pasteboard.