Struct composition using KeyPath
and @dynamicMemberLookup
(kind of struct subclassing, but better)
You can read this same post, with syntax highlighting at SForSwift
Today I want to revisit struct composition in Swift. I will dive right into it, but if you need some extra context you can have a look at this article by John Sundell.
Let’s say that in our company there are several developers:
struct Developer {
var name: String
var age: Int
}
Some of them work on site, and others remotelly. The ones working remotelly are based on a specific location:
struct RemoteLocation {
var country: String
var city: String
}
Now, if we were using composition, a developer working remotelly would be declared and used as follows:
struct RemoteDeveloper {
var developer: Developer
var remoteLocation: RemoteLocation
}let remoteDeveloper = RemoteDeveloper(
developer: .init(name: "Andres", age: 26),
remoteLocation: .init(country: "Spain", city: "Madrid")
)
print(remoteDeveloper.developer.name) // Andres
print(remoteDeveloper.remoteLocation.city) // Madrid
See the problems?
- We need to declare a new
RemoteDeveloper
type. - Accessing the properties of a remote developer is verbose, as it requires accessing the nested structs.
- These are just two structs: the more structs to compose, the more nested levels there will be.
There are multiple ways to solve this problem, for example using computed properties, but they are verbose and not very elegant. In addition, things can get more complex if you need these structs to be decoded/encoded.
Key paths and Dynamic Member Lookup to the rescue
Ideally, our RemoteDeveloper
struct would just put together the properties of a Developer
and a RemoteLocation
. Using KeyPath
and @dynamicMemberLookup
it is possible to simulate that.
Let’s start by declaring the following type:
@dynamicMemberLookup
struct Compose<Element1, Element2> {
var element1: Element1
var element2: Element2 subscript<T>(dynamicMember keyPath: WritableKeyPath<Element1, T>) -> T {
get { element1[keyPath: keyPath] }
set { element1[keyPath: keyPath] = newValue }
} subscript<T>(dynamicMember keyPath: WritableKeyPath<Element2, T>) -> T {
get { element2[keyPath: keyPath] }
set { element2[keyPath: keyPath] = newValue }
} init(_ element1: Element1, _ element2: Element2) {
self.element1 = element1
self.element2 = element2
}
}
Now, our remote developer can be declared as:
typealias RemoteDeveloper = Compose<Developer, RemoteLocation>
And used as:
let remoteDeveloper = RemoteDeveloper(
.init(name: "Andres", age: 26),
.init(country: "Spain", city: "Madrid")
)
print(remoteDeveloper.name) // Andres
print(remoteDeveloper.city) // Madrid
Of course accessing the properties is type safe, and you also get autocompletion for them.
Using it together with Codable
To use this approach with Codable
, we need to extend our Compose
struct:
extension Compose: Encodable where Element1: Encodable, Element2: Encodable {
public func encode(to encoder: Encoder) throws {
try element1.encode(to: encoder)
try element2.encode(to: encoder)
}
}extension Compose: Decodable where Element1: Decodable, Element2: Decodable {
public init(from decoder: Decoder) throws {
self.element1 = try Element1(from: decoder)
self.element2 = try Element2(from: decoder)
}
}
Now we can encode/decode our remote developer as follows:
let remoteDeveloperJson = """
{
"age" : 26,
"city" : "Madrid",
"country" : "Spain",
"name" : "Andres"
}
"""let decoder = JSONDecoder()
let remoteDeveloper = try decoder.decode(
RemoteDeveloper.self,
from: Data(remoteDeveloperJson.utf8)
)
Composing multiple structs
It is also possible to compose multiple structs. For example, let’s say that the previous remote developer is also a team lead:
struct TeamLead: Codable, Hashable {
var team: String
var salary: Int
}typealias RemoteTeamLead = Compose<TeamLead, RemoteDeveloper>
/*
Note. You could also do:
typealias Compose3<T1, T2, T3> = Compose<T1, Compose<T2, T3>>
typealias RemoteTeamLead = Compose3<TeamLead, Developer, RemoteLocation>
*/let remoteTeamLead = RemoteTeamLead(.init(team: "iOS", salary: 1000000), remoteDeveloper)
print(remoteDeveloper.name) // Andres
print(remoteDeveloper.city) // Madrid
print(remoteTeamLead.team) // iOS
Room for improvement
At the moment KeyPath
only supports properties, so for instance methods you will need to reach down the nested objects. You can work around this by declaring closures instead of functions, but it is not a very elegant solution. There has been some discussion about adding support for instance methods in KeyPath
, but it did not get enough traction.
Finally
Yes, I put together a swift package with the Compose
struct, so you can start using it all over the codebase right away 🎉
Find it here: https://github.com/acecilia/Compose