SForSwift

Struct composition using KeyPath and @dynamicMemberLookup (kind of struct subclassing, but better)

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