Articles, podcasts and news about Swift development, by John Sundell.

A deep dive into Swift’s result builders

Remastered on 07 Apr 2021
Discover page available: SwiftUI

Swift’s result builders feature is arguably one of the most interesting recent additions to the language, as it plays a core part in making SwiftUI’s declarative, DSL-like API work the way it does. In fact, result builders were first introduced as a semi-official language feature called “function builders” as part of the Swift 5.1 release that accompanied the introduction of SwiftUI, but has since been promoted into a proper part of the language as of Swift 5.4.

In this article, let’s take a closer look at how result builders work and how we can use them, as well as how they can give us some really valuable insights into how SwiftUI’s API operates under the hood.

Setting things up

For me, one of the best ways to truly understand how a given Swift feature works is to actually build something with it, so that’s what we’ll do. As an example, let’s say that we’re working on an app that includes an API for defining various settings — using a Setting type that looks like this:

struct Setting {
    var name: String
    var value: Value
}

extension Setting {
    enum Value {
        case bool(Bool)
        case int(Int)
        case string(String)
        case group([Setting])
    }
}

The above example type uses associated enum values to ensure complete type safety even though various settings can contain different types of values. To learn more about that pattern, check out the Basics article about enums.

Since the above type includes support for nested settings (through its group value), we’re able to use it to construct hierarchies. For example, here we’ve created a dedicated group for all of our settings that are considered experimental:

let settings = [
    Setting(name: "Offline mode", value: .bool(false)),
    Setting(name: "Search page size", value: .int(25)),
    Setting(name: "Experimental", value: .group([
        Setting(name: "Default name", value: .string("Untitled")),
        Setting(name: "Fluid animations", value: .bool(true))
    ]))
]

While there’s certainly nothing really wrong with the above API (in fact, it’s quite nice!), let’s see what it could end up looking like if we were to give it a “result builders makeover” — which in turn could let us transform it into more of a DSL, similar to what SwiftUI offers.

The basics of how result builders work

Like its name implies, Swift’s result builders feature essentially lets us build a result by combining multiple expressions into a single value. Within SwiftUI, that’s used to transform the contents of one of its many containers (such as HStack or VStack) into a single enclosing view, which can be seen by calling the type(of:) function on such a container instance:

import SwiftUI

let stack = VStack {
    Text("Hello")
    Text("World")
    Button("I'm a button") {}
}

// Prints 'VStack<TupleView<(Text, Text, Button<Text>)>>'
print(type(of: stack))

In general, anytime we see TupleView when using SwiftUI, that means that a result builder has been used to combine multiple views into one.

SwiftUI uses a number of different result builder implementations, such as ViewBuilder and SceneBuilder, but since we’re not able to look into the source code for those types, let’s instead build our own result builder for the settings API that we took a look at above.

Just like a property wrapper, a result builder is implemented as a normal Swift type that’s annotated with a special attribute — @resultBuilder in this case. Then, specific method names are used to implement its various capabilities. For example, a method named buildBlock with zero arguments is used to build the result of an empty function or closure:

@resultBuilder
struct SettingsBuilder {
    static func buildBlock() -> [Setting] { [] }
}

The return type of the above function (an array of Setting values in our case) then determines the type of function or closure that our builder can be applied to. For example, we might choose to implement our top-level settings API as a global function that applies our new SettingsBuilder to any closure that was passed into it — like this:

func makeSettings(@SettingsBuilder _ content: () -> [Setting]) -> [Setting] {
    content()
}

With the above in place, we can now call makeSettings with an empty trailing closure and we’ll get an empty array back:

let settings = makeSettings {}

While our new API is not yet very useful, it’s already showed us a few aspects of how result builders work. But now, let’s actually start building some proper results.

Combining multiple values into a single result

To enable our SettingsBuilder to accept input, all that we have to do is to declare additional overloads of buildBlock with arguments matching the input that we’re looking to receive. In our case, we’ll simply implement a single method that accepts a list of Setting values, which we’ll then return as an array — like this:

extension SettingsBuilder {
    static func buildBlock(_ settings: Setting...) -> [Setting] {
        settings
    }
}

Above we’re using a variadic argument list, which SwiftUI can’t currently use, since its View protocol contains an associated type. Instead, SwiftUI’s ViewBuilder defines 10 different overloads of buildBlock, each with a different number of arguments — which is why a SwiftUI view can’t have more than 10 children. However, that limitation does not apply to our SettingsBuilder.

With that new buildBlock overload in place, we’ll now be able to fill any closure that we’re passing to makeSettings with Setting values, and our result builder (with some help from the compiler) will combine all of those expressions into an array, which is then returned:

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    Setting(name: "Experimental", value: .group([
        Setting(name: "Default name", value: .string("Untitled")),
        Setting(name: "Fluid animations", value: .bool(true))
    ]))
}

While the above is arguably already a slight improvement over the inline array that we were previously using, let’s continue to take inspiration from SwiftUI, and also add a result builder-powered API for defining groups. To make that happen, let’s start by defining a new SettingsGroup type that also annotates a closure (this time stored in a property) with the @SettingsBuilder attribute in order to connect it to our result builder:

struct SettingsGroup {
    var name: String
    @SettingsBuilder var settings: () -> [Setting]
}

An alternative approach would’ve been to instead implement a custom initializer (rather than relying on Swift’s memberwise initializers feature) and to then immediately call our settings closure and store its result, rather than storing a reference to the closure itself. That has the benefit of avoiding having to make our closure escaping at the cost of a slightly more verbose implementation that could prove to be a bit more performant, but also less flexible (as the closure will now only be called once, up front):

struct SettingsGroup {
    var name: String
    var settings: [Setting]

    init(name: String,
         @SettingsBuilder builder: () -> [Setting]) {
        self.name = name
        self.settings = builder()
    }
}

With either of the above two implementations in place (let’s go with the first one for now), we’re now able to define groups the exact same way as when defining top-level settings — by simply expressing each nested Setting within a closure, like this:

SettingsGroup(name: "Experimental") {
    Setting(name: "Default name", value: .string("Untitled"))
    Setting(name: "Fluid animations", value: .bool(true))
}

However, if we actually try to place the above group within our makeSettings closure, we’ll end up getting a compiler error — since our result builder’s buildBlock method currently expects a variadic list of Setting values, and our new SettingsGroup is a completely different type.

To fix that issue, let’s introduce a thin abstraction that can be shared between both Setting and SettingsGroup, for example in the shape of a protocol that lets us convert any instance of those types into an array of Setting values:

protocol SettingsConvertible {
    func asSettings() -> [Setting]
}

extension Setting: SettingsConvertible {
    func asSettings() -> [Setting] { [self] }
}

extension SettingsGroup: SettingsConvertible {
    func asSettings() -> [Setting] {
        [Setting(name: name, value: .group(settings()))]
    }
}

Then, we simply have to modify our result builder’s buildBlock implementation to accept SettingsConvertible instances, rather than concrete Setting values, and we’ll then flatten that new argument list using flatMap:

extension SettingsBuilder {
    static func buildBlock(_ values: SettingsConvertible...) -> [Setting] {
        values.flatMap { $0.asSettings() }
    }
}

With the above in place, we can now define all of our settings in a very “SwiftUI-like” way, by constructing groups just like how we’d organize our various SwiftUI views using stacks and other containers:

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    SettingsGroup(name: "Experimental") {
        Setting(name: "Default name", value: .string("Untitled"))
        Setting(name: "Fluid animations", value: .bool(true))
    }
}

Really nice! So the buildBlock overloads that a given result builder contains directly determines what type of expressions that we’ll be able to place within each closure or function that has been annotated to use that builder.

Conditionals

Next, let’s take a look at how we can add support for evaluating conditionals within our result builder-powered closures. Initially, it might seem like that should “just work”, given that Swift itself supports all kinds of different conditionals. However, that’s not the case — so with our current SettingsBuilder implementation we’ll end up getting a compiler error if we try to do something like this:

let shouldShowExperimental: Bool = ...

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    // Compiler error: Closure containing control flow statement
    // cannot be used with result builder 'SettingsBuilder'.
    if shouldShowExperimental {
        SettingsGroup(name: "Experimental") {
            Setting(name: "Default name", value: .string("Untitled"))
            Setting(name: "Fluid animations", value: .bool(true))
        }
    }
}

The above example once again shows us that the code that’s being executed within a result builder-annotated closure isn’t treated the same way as “normal” Swift code — as each expression needs to be explicitly handled by our builder, including conditionals like if statements.

To add that sort of handling code, we’ll need to implement the buildIf method, which is what the compiler will map each stand-alone if statement to. Since each such statement can evaluate to either true or false, we’ll get its body expression passed as an optional — which in our case will look like this:

// Here we extend Array to make it conform to our SettingsConvertible
// protocol, in order to be able to return an empty array from our
// 'buildIf' implementation in case a nil value was passed:
extension Array: SettingsConvertible where Element == Setting {
    func asSettings() -> [Setting] { self }
}

extension SettingsBuilder {
    static func buildIf(_ value: SettingsConvertible?) -> SettingsConvertible {
        value ?? []
    }
}

With the above in place, our if statement from before now works just as we’d expect. But let’s also add support for combined if/else statements, which can be done by implementing two overloads of the buildEither method — one with the parameter label first, and one with second, each corresponding to the first and second branch of a given if/else statement:

extension SettingsBuilder {
    static func buildEither(first: SettingsConvertible) -> SettingsConvertible {
        first
    }

    static func buildEither(second: SettingsConvertible) -> SettingsConvertible {
        second
    }
}

We’ll now be able to add an else clause to our if statement from before, for example in order to let users request access to our app’s experimental settings if those are not yet shown:

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    if shouldShowExperimental {
        SettingsGroup(name: "Experimental") {
            Setting(name: "Default name", value: .string("Untitled"))
            Setting(name: "Fluid animations", value: .bool(true))
        }
    } else {
        Setting(name: "Request experimental access", value: .bool(false))
    }
}

Finally, those buildEither methods that we just implemented now (as of Swift 5.3) also enable switch statements to be used within result builder contexts, without requiring any additional build methods.

So for example, let’s say that we’re looking to refactor our above shouldShowExperimental boolean into an enum, in order to support multiple access levels. We could then simply switch on that enum within our makeSettings closure, and the Swift compiler will automatically route those expressions into our buildEither methods from before:

enum UserAccessLevel {
    case restricted
    case normal
    case experimental
}

let accesssLevel: UserAccessLevel = ...

let settings = makeSettings {
    Setting(name: "Offline mode", value: .bool(false))
    Setting(name: "Search page size", value: .int(25))

    switch accesssLevel {
    case .restricted:
        Setting.Empty()
    case .normal:
        Setting(name: "Request experimental access", value: .bool(false))
    case .experimental:
        SettingsGroup(name: "Experimental") {
            Setting(name: "Default name", value: .string("Untitled"))
            Setting(name: "Fluid animations", value: .bool(true))
        }
    }
}

One additional thing worth noting about the above code is that we’re using a new Setting.Empty type within our switch statement’s .restricted case. That’s because we’re not (yet) able to use the break keyword within a result builder switch statement, so we’ll need to express some kind of value within each code branch. So just like how SwiftUI has EmptyView, our new Settings API now has a Setting.Empty type for those kinds of situations:

extension Setting {
    struct Empty: SettingsConvertible {
        func asSettings() -> [Setting] { [] }
    }
}

And with that, our new result builder-powered settings API is now finished! It’s really quite fascinating just how little code that’s required to build a SwiftUI-like DSL using this new language feature.

Conclusion

With features like property wrappers and result builders, Swift is moving into some very interesting new territories, by enabling us to add our own logic to various fundamental language mechanisms — like how expressions are evaluated, or how properties are assigned and stored.

Granted, those new features do also make Swift more complicated, even though (at least in the best of worlds), they could also let library designers — both at Apple and in the wider developer community — hide that complexity behind well-formed APIs.

What do you think? Are you looking forward to using result builders within your own code, and did you gain some additional insight into how SwiftUI’s API works by reading this article? If so, feel free to share it, and you’re also more than welcome to contact me (either via Twitter or email) if you have any questions, comments, or feedback.

Thanks for reading! 🚀