A deep dive into Swift’s result builders
Discover page available: SwiftUISwift’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! 🚀