Builders in Swift
Those of us who have written Java code before probably know what the builder pattern is. And those of us who dislike Java as much as I do are probably confused why you would want builders in Swift.
But, as with most things, it heavily depends on the context. And builders aren't inherently bad. For example they are mostly just overused in Java.
I've recently come across a situation in Swift, where I needed an object of some kind that could dynamically be extended with stored properties. They had to be stored, because they were non-trivial to compute. And because of this, I also didn't want to compute all of them in places where I might need only a hand full. And finally, I didn't want to deal with Optional
s - the computation already throws an error if it fails, so once it succeeds it should be guaranteed to be there and not need another validation (or worse even: an !
).
In short: I wanted something that could be pieced together dynamically but still be verified by the compiler.
The Basics
To make this work in Swift, the @dynamicMemberLookup
feature is critical. It allows an object to act as a proxy for one or more objects. For details see SE-195 and SE-252. Also, opaque result types (SE-244) and primary associated types (SE-346) are needed.
It all starts with a base protocol (in my case it already had associated types, but that's not needed for the basic example):
public protocol BuilderBase {}
This protocol will act as the base from which the next protocol will inherit and on which the extending function(s) will be defined.
Let's add an implementation for it:
struct MyBuilder: BuilderBase {}
This implementation will be the entry point for the builder chain, but also not much more. In my concrete usecase, this was even a private struct - an implementation detail hidden behind a different entrypoint function.
Now, let's add the magic. For this, we first add a sub-protocol:
@dynamicMemberLookup
public protocol Builder<Base, Lookup>: BuilderBase {
associatedtype Base: BuilderBase
associatedtype Lookup
subscript<T>(dynamicMember basePath: KeyPath<Base, T>) -> T { get }
subscript<T>(dynamicMember lookupPath: KeyPath<Lookup, T>) -> T { get }
}
Here we define a protocol that has two associated types (which importantly are also defined as primary). Also, this protocol requires the @dynamicMemberLookup
implementation and also defines which subscripts need to be provided. This is actually the magic behind it. The implementing object (which we'll add in a second) will be able to access properties of both the Base
and the Lookup
types. The Lookup
type will contain the new properties we want to add to the builder. The Base
, however, will be recursively contain the "parent" builder. And since this "parent" is also able to dynamically lookup properties of its Lookup
type, we're now able to add a theoretically unlimited number of properties.
Let's add the implementation:
fileprivate struct _ExtendedBuilder<ParentBuilder, PropertiesLookup>: Builder
where ParentBuilder: BuilderBase
{
typealias Base = ParentBuilder
typealias Lookup = PropertiesLookup
let base: Base
let lookup: Lookup
subscript<T>(dynamicMember basePath: KeyPath<Base, T>) -> T {
base[keyPath: basePath]
}
subscript<T>(dynamicMember lookupPath: KeyPath<Lookup, T>) -> T {
lookup[keyPath: lookupPath]
}
}
Notice how this type is fileprivate
! It's an implementation detail that users don't need to know about. It's created using the following function:
extension BuilderBase {
public func withProperties<PropertiesLookup>(of lookup: PropertiesLookup) -> some Builder<Self, PropertiesLookup> {
_ExtendedBuilder(base: self, lookup: lookup)
}
}
Since this function is defined on the BuilderBase
protocol (the base of all builders), we can also call it on implementations our Builder
.
And that's all that's needed.
The Extensions
Now we can start adding properties (e.g. in a different file or even module):
import Foundation
struct Greeting {
let greeting: String
}
extension BuilderBase {
func withGreeting(_ greeting: String) -> some Builder<Self, Greeting> {
withProperties(of: Greeting(greeting: greeting))
}
}
struct Person {
let firstName: String
let lastName: String
}
extension BuilderBase {
func withPerson(firstName: String, lastName: String) -> some Builder<Self, Person> {
withProperties(of: Person(firstName: firstName, lastName: lastName))
}
}
struct Age {
let birthday: Date
let ageInYears: Int
}
enum AgeCalculationError: Error {
case birthdayInFuture
case unableToCalculateAge
}
extension BuilderBase {
func withAge(bornAt birthday: Date) throws -> some Builder<Self, Age> {
guard birthday < .now else { throw AgeCalculationError.birthdayInFuture }
guard let age = Calendar.current.dateComponents([.year], from: birthday, to: .now).year
else { throw AgeCalculationError.unableToCalculateAge }
return withProperties(of: Age(birthday: birthday, ageInYears: age))
}
}
The Usage
Finally, we can call these new functions and build our values:
do {
let values = try MyBuilder()
.withGreeting("Hello")
.withPerson(firstName: "John", lastName: "Appleseed")
.withAge(bornAt: Date(timeIntervalSince1970: 20 * 60 * 60 * 24 * 365))
print(values.greeting, values.firstName, values.lastName)
print(values.birthday)
print(values.ageInYears)
} catch {
print("Failed to build values: \(error)")
}
The compiler will typecheck our code and we only have access to values we previously added:
To prove this, let's remove the call to withGreeting
:
The greeting
is no longer in the list of available properties! And trying to access it will lead to a compiler error (which isn't pinpointing exactly what's wrong, but that's a different topic):
The Pitfalls
There are a few pitfalls to be aware of, though, that can lead to confusion otherwise.
Overrides
The added properties are added in a flat manner. This means that previously defined properties with the same name will be overridden!
struct Person {
let name: String
}
struct Dog {
let name: String
}
extension BuilderBase {
func withPerson(named name: String) -> some Builder<Self, Person> {
withProperties(of: Person(name: name))
}
func withDog(named name: String) -> some Builder<Self, Dog> {
withProperties(of: Dog(name: name))
}
}
let values = MyBuilder()
.withPerson(named: "John Appleseed")
.withDog(named: "Rex")
print(values.name) // Will print "Rex"!
You can work around this by making the outer structs contain inner structs (or tuples) with the actual values:
struct PersonBag {
struct Properties {
let name: String
}
let person: Properties
init(name: String) {
person = .init(name: name)
}
}
struct DogBag {
let dog: (name: String, isAGoodBoy: Bool)
init(name: String) {
dog = (name, true)
}
}
extension BuilderBase {
func withPerson(named name: String) -> some Builder<Self, PersonBag> {
withProperties(of: PersonBag(name: name))
}
func withDog(named name: String) -> some Builder<Self, DogBag> {
withProperties(of: DogBag(name: name))
}
}
let values = MyBuilder()
.withPerson(named: "John Appleseed")
.withDog(named: "Rex")
print(values.person.name) // Prints "John Appleseed"
print(values.dog.name) // Prints "Rex"
print(values.dog.isAGoodBoy) // Prints "true"
Performance and Memory
It's worth keeping in mind that the builder instances are recursively nested into each other on every addition.
However, this should not behave too bad memory wise, since these are all structs which take up little memory themselves.
Accessing the built properties has to (in the worst case) move down to the builder at the bottom of the builder stack. Since those are all keypath-lookups, it should have decent performance, though (since KeyPath
s access fields directly in most cases).
I haven't verified this in places performance and/or memory is actually critical, though. As always: YMMV.
One more thing: Technically not a builder
Some of you might have correctly realized by now, that this isn't a real builder since it's not finalized (e.g. you can always continue 'building' on values
in the examples).
This is true and there is a way to make it like a real builder. However, it didn't bring much value to my usecase which is why I didn't do it.
It's totally possible to add this, however. The base code would undergo some refactoring, though, and finalizing requires walking up the chain of stacked builders. The new base code could look something like this:
@dynamicMemberLookup
protocol BuiltResult<Base, Lookup> where Base: BuiltResult {
associatedtype Base
associatedtype Lookup
subscript<T>(dynamicMember basePath: KeyPath<Base, T>) -> T { get }
subscript<T>(dynamicMember lookupPath: KeyPath<Lookup, T>) -> T { get }
}
protocol Builder<Base, Lookup> where Base: Builder {
associatedtype Base
associatedtype Lookup
associatedtype Result: BuiltResult<Base.Result, Lookup>
func finalize() -> Result
}
struct EmptyResult: BuiltResult {
typealias Base = EmptyResult
typealias Lookup = Void
subscript<T>(dynamicMember basePath: KeyPath<Base, T>) -> T {
self[keyPath: basePath]
}
subscript<T>(dynamicMember lookupPath: KeyPath<Lookup, T>) -> T {
()[keyPath: lookupPath]
}
}
extension Never: Builder {
typealias Base = Never
typealias Lookup = Void
typealias Result = EmptyResult
func finalize() -> Result { fatalError("unreachable") }
}
struct MyBuilder: Builder {
typealias Base = Never
typealias Lookup = Void
func finalize() -> some BuiltResult<EmptyResult, Lookup> {
EmptyResult()
}
}
fileprivate struct _BuiltResult<ParentResult, PropertiesLookup>: BuiltResult
where ParentResult: BuiltResult
{
typealias Base = ParentResult
typealias Lookup = PropertiesLookup
let base: Base
let lookup: Lookup
subscript<T>(dynamicMember basePath: KeyPath<Base, T>) -> T {
base[keyPath: basePath]
}
subscript<T>(dynamicMember lookupPath: KeyPath<Lookup, T>) -> T {
lookup[keyPath: lookupPath]
}
}
fileprivate struct _ExtendedBuilder<ParentBuilder, PropertiesLookup>: Builder
where ParentBuilder: Builder
{
typealias Base = ParentBuilder
typealias Lookup = PropertiesLookup
let base: Base
let lookup: Lookup
func finalize() -> some BuiltResult<Base.Result, Lookup> {
_BuiltResult(base: base.finalize(), lookup: lookup)
}
}
extension Builder {
func withProperties<PropertiesLookup>(of lookup: PropertiesLookup) -> some Builder<Self, PropertiesLookup> {
_ExtendedBuilder(base: self, lookup: lookup)
}
}
The extensions now go on Builder
(instead of BuilderBase
as before) and a finalize
call is now needed to access the properties.
The EmptyResult
type is needed (and uses Void
as Lookup
) to provide an empty type as the root. We could use Never
here, but that would offer all kinds of (inaccessible) properties on Never
(e.g. from it conforming to Identifiable
) and lead to runtime crashes if someone actually uses it.
The code using it would then look like this:
struct PersonBag {
struct Properties {
let name: String
}
let person: Properties
init(name: String) {
person = .init(name: name)
}
}
struct DogBag {
let dog: (name: String, isAGoodBoy: Bool)
init(name: String) {
dog = (name, true)
}
}
extension Builder {
func withPerson(named name: String) -> some Builder<Self, PersonBag> {
withProperties(of: PersonBag(name: name))
}
func withDog(named name: String) -> some Builder<Self, DogBag> {
withProperties(of: DogBag(name: name))
}
}
let values = MyBuilder()
.withPerson(named: "John Appleseed")
.withDog(named: "Rex")
.finalize()
print(values.person.name) // Prints "John Appleseed"
print(values.dog.name) // Prints "Rex"
print(values.dog.isAGoodBoy) // Prints "true"
Notice how only builder functions can be accessed without the finalize
call:
And how after the finalize
call, these builder functions are no longer accessible, but only the built values:
This can actually make a nice API addition if needed!