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 Optionals - 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:

Code completion shows all properties we added.

To prove this, let's remove the call to withGreeting:

The greeting is no longer offered in the list of properties.

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 compiler noticing that this won't work.

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 KeyPaths 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:

Only the builder methods are offered.

And how after the finalize call, these builder functions are no longer accessible, but only the built values:

Only the built properties are left.

This can actually make a nice API addition if needed!