Using dynamic member lookup to implement the builder pattern
February 23, 2020
SwiftUI’s syntax is unmistakable in regards to building views — instead of assigning properties to your view, you just apply a modifier that returns another view:
Text("Hello, world!")
.bold()
.foregroundColor(.red)
This “style” of writing views is very similar to the builder pattern in other languages. But let’s say that you already have a bunch of existing types in your project that you’re refactoring to use the builder pattern, or you want to apply the builder pattern to types from other libraries. It would definitely be tedious to create functions for every property in every type. Let’s see if we can use Swift’s powerful type system to do the leg work for us!
Note that I’m not advocating for or against using the builder pattern in a specific project — this article is more of an exploration into how to implement it in a generic fashion. With that said, let’s get started!
It turns out that Swift actually lets us dynamically access properties on a given type, without having to know anything about that type beforehand, using something called dynamic member lookup. All you need to do is add the @dynamicMemberLookup
attribute to your type, and then you can access any random property of the constraints you choose:
@dynamicMemberLookup
struct SomeType {
...
}
let someValue = SomeType()
someValue.foo // works
someValue.someRandomProperty // works
someValue.asdfghjkl // works
Dynamic member lookup is actually just syntax sugar for a special variant of a subscript, and it looks like this:
@dynamicMemberLookup
struct SomeType {
subscript(dynamicMember key: String) -> Any {
...
}
}
someValue.foo == someValue[dynamicMember: "foo"]
In addition to passing an arbitrary string, you can also constrain both the dynamicMember:
parameter and the subscript’s return value to a specific type. You can even use generics! This will make it a lot easier to implement what we’re aiming for, so let’s start by creating a generic Builder
type:
@dynamicMemberLookup
struct Builder<T> {
private var value: T
init(_ value: T) {
self.value = value
}
}
We can use this by calling Builder(MyType())
. Great! Now let’s implement the subscript:
extension Builder {
subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>) -> (U) -> Builder<T> {
return { self.value[keyPath: keyPath] = $0 }
}
}
Woah! There’s a lot going on there, so let’s break it down step by step.
The first thing that we need to focus on is the use of key paths — if you haven’t heard of them before, key paths are a concise way to represent a property on a specific type. You write them as \MyType.property
, or just \.property
if MyType
can be inferred. One good use of key paths is when you’re mapping an array:
let names = ["alice", "bob", "charles"]
names.map { $0.capitalized } // ["Alice", "Bob", "Charles"]
names.map(\.capitalized)
Here, a key path is being used to specify that we want to access the capitalized
property on each string. It’s the same as creating a closure that returns the capitalized
property, but it’s a bit more concise. The type of a key path is KeyPath<T, U>
, where T
is the type of the value you’re accessing the property of, and U
is the type of the property being accessed.
Back to our example:
subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>)
So here instead of using a regular key path, we’re just using a writable key path — same thing, except we also get to assign values to the property being accessed. By constraining the type of dynamicMember:
to a key path, we only allow users of Builder
to call functions whose name and type match that of the original value they passed into the Builder
instance. And we get code completion now, too — neat!
If that didn’t make 100% sense, here’s an example in code:
@dynamicMemberLookup
struct UnconstrainedAccess {
subscript(dynamicMember key: String) -> Any {
...
}
}
let unconstrained = UnconstrainedAccess()
unconstrained.foo // works
unconstrained.asdfghjkl // works
...
struct Person {
var name: String
var age: Int
}
@dynamicMemberLookup
struct ConstrainedAccess {
subscript<U>(dynamicMember keyPath: KeyPath<Person, U>) -> U {
// Any arbitrary key path can be accessed using the
// value[keyPath: ...] subscript. foo[keyPath: \.bar] == foo.bar
return self.someInstanceOfPerson[keyPath: keyPath]
}
}
let constrained = ConstrainedAccess(...)
constrained.name // works, is of type String
constrained.age // works, is of type Int
constrained.foo // error!
// Also note that this is all still just syntax sugar:
constrained.name == constrained[dynamicMember: \Person.name]
Now let’s focus on what our subscript is returning:
subscript<U>(...) -> (U) -> Builder<T> {
return { self.value[keyPath: keyPath] = $0 }
}
In the previous example with ConstrainedAccess
, we implemented a dynamic member lookup that just sent back the value of the property accessed by the key path. Here, we’re sending back a function that can be called with the value we want to assign to the given property of the underlying self.value
. Then from that function, we return the original Builder
instance, letting us chain multiple calls together. This gives us our SwiftUI-like syntax:
Builder(someInstanceOfPerson)
.name("Alice")
.age(42)
If you’ve been following along really closely, you may have said “But wait! That resolves to a value of Builder<Person>
, not Person
!” You would be correct — for that, we can just define a build()
function that returns the underlying value:
extension Builder {
func build() -> T { self.value }
}
So now we can do:
Builder(someInstanceOfPerson)
.name("Alice")
.age(42)
.build()
And we get a Person
value! There’s one more issue, though — we have to pass in an existing instance of Person
, just so its values can be modified once again. Ideally we’d use our builder implementation to “initialize” our value from start to finish. In this case, we can define a Buildable
protocol that requires an empty initializer, so any properties on the type we want to use must have default values:
protocol Buildable {
init()
}
And to make our implementation even more concise, we can define a static builder()
function directly on the Buildable
protocol:
extension Buildable {
static func builder() -> Builder<Self> {
Builder(Self())
}
}
And now adopting the builder pattern is a breeze!
struct Person: Buildable {
// Make sure to provide default values
var name: String = ""
var age: Int = 0
}
Person.builder()
.name("Alice")
.age(42)
.build()
Here’s the entire implementation of Builder
and Buildable
:
@dynamicMemberLookup
struct Builder<T> {
private var value: T
init(_ value: T) {
self.value = value
}
subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>) -> (U) -> Builder<T> {
return { self.value[keyPath: keyPath] = $0 }
}
}
protocol Buildable {
init()
}
extension Buildable {
static func builder() -> Builder<Self> {
Builder(Self())
}
}
One interesting potential use case for this could be, in fact, SwiftUI related — take a look 👀
self.view.addSubview(
UIImageView.builder()
.image(UIImage(named: "..."))
.contentMode(.scaleAspectFit)
.tintColor(.red)
.constraints {
// implementation of the `constraints` builder
// method is left as an exercise to the reader :)
}
.build()
)
I hope you enjoyed this article — I hope to post more like it soon! Thanks for reading!