Advanced Swift · advanced

Result builders

Why a closure full of bare expressions is legal Swift — the feature that makes body work, demystified by building one.

11 min read12 min practice0/1 exercises4 recall cards

By the end you will be able to

  • Explain how a result builder transforms the statements in a closure
  • Trace what ViewBuilder does with an if/else in a body
  • Build a small result builder and know when one is worth it
Guess firstAnswering before you read makes the explanation stick — even when you get it wrong.

In ordinary Swift, a closure body of two expressions with no return{ Text("a") Text("b") } — is a compile error. Inside a SwiftUI body it works. What makes the difference?

The transformation

A result builder is a type marked @resultBuilder with static build… methods. When a closure is marked with it, the compiler rewrites the closure's statements into calls on those methods.

You write:

VStack {
    Text("Title")
    Text("Subtitle")
    Image(systemName: "star")
}

The compiler emits, roughly:

VStack(content: {
    ViewBuilder.buildBlock(
        Text("Title"),
        Text("Subtitle"),
        Image(systemName: "star")
    )
})

Each bare expression becomes an argument to buildBlock, and buildBlock returns the single combined value — for ViewBuilder, a TupleView of the three. That is the entire trick behind SwiftUI's syntax: statements in, one value out, via static methods you can read.

Build one and run it

As with property wrappers, this runtime has no macro expansion — so we do the compiler's rewrite by hand, which doubles as proof of how little is hidden. A builder for breadcrumb-style path strings:

PathBuilder, with the compiler's rewrite written out by hand.

Read the generated version against the commented original, piece by piece:

You writeCompiler callsPurpose
a bare expressioncollected into buildBlock(…)combine the statements
if condition { X } (no else)buildOptional(condition ? X : nil)the branch may produce nothing
if … { X } else { Y }buildEither(first:) / buildEither(second:)either branch, tagged
for item in items { X }buildArray([…])a component per iteration

There are a few more hooks (buildExpression, buildLimitedAvailability for if #available), but these four are the working set — and every one is just a static method you could write.

Reading body with new eyes

Now look at what this explains about SwiftUI you have already used:

Why ForEach exists. A builder only supports the statements its author implemented hooks for — and ViewBuilder deliberately implements no buildArray, so a plain for loop inside a body does not compile at all. The design reason is identity: SwiftUI must know which row moved and which was deleted, and anonymous loop iterations cannot carry that. Run the refused world forward: with an anonymous loop, deleting row 2 could only be diffed by position — every row below shifts its content up one slot while keeping its position-based identity, so row 5's expansion state and in-flight animation now belong to what used to be row 6. ForEach is a runtime view that owns identity — which is why lists use it instead of a for loop.

Why branches change view identity. if isCompact { SmallCard() } else { BigCard() } compiles to buildEither — the result is literally "first branch" or "second branch", a tagged either-type. When the condition flips, SwiftUI sees a different branch, not a changed view: state resets, transitions run. The animation lesson builds on exactly this.

Why body has one expression most of the time. buildBlock historically capped out at 10 arguments (SwiftUI shipped overloads up to TupleView<(C0…C9)>), which is why more than ten sibling views once forced a Group. Swift 5.9's parameter packs removed that cap back in Xcode 15 — one variadic buildBlock replaced the overload pile. Xcode 27's unified ContentBuilder goes further, merging the separate per-container builders (toolbars, commands and friends each had their own) — and materially speeding up type-checking, which was the real cost of the old design.

Predict, then runA wrong prediction you have committed to is worth more than a right answer you read.

buildArray handles loops: each iteration produces a component, and the builder receives them all. What does this print?

struct ListBuilder {
    static func buildBlock(_ items: [String]...) -> [String] {
        var all: [String] = []
        for group in items {
            all.append(contentsOf: group)
        }
        return all
    }

    static func buildArray(_ groups: [[String]]) -> [String] {
        var all: [String] = []
        for group in groups {
            all.append(contentsOf: group)
        }
        return all
    }

    static func buildExpression(_ item: String) -> [String] {
        [item]
    }
}

// Hand-expansion of:
//   @ListBuilder var menu: [String] {
//       "Home"
//       for section in ["News", "Sport"] {
//           section
//           "\(section) archive"
//       }
//   }

func menu() -> [String] {
    let a = ListBuilder.buildExpression("Home")
    var iterations: [[String]] = []
    for section in ["News", "Sport"] {
        let x = ListBuilder.buildExpression(section)
        let y = ListBuilder.buildExpression("\(section) archive")
        iterations.append(ListBuilder.buildBlock(x, y))
    }
    let b = ListBuilder.buildArray(iterations)
    return ListBuilder.buildBlock(a, b)
}

print(menu())
Check yourself

Why does SwiftUI reset a view's @State when an if/else around it flips?

Build a ReceiptBuilder

Write the code

Model a checkout receipt as a builder. Implement the three methods so the hand-expanded receipt(...) below produces the expected output.

Solution unlocks after 3 attempts
Explain itSaved on this device. Never graded.

SwiftUI could have shipped without result builders — VStack(content: [Text("a"), Text("b")]) with plain arrays. In your own words: what would be worse about that API, and what one thing might actually be better? (There is a real answer to the second part — think about compile times, then check what Xcode 27's ContentBuilder change was fixing.)

Checkpoint

You can now:

  • Expand a builder closure into the static build… calls the compiler makes
  • Explain buildBlock, buildOptional, buildEither, buildArray and buildExpression
  • Say why ForEach exists and why branch flips reset state
  • Judge when a builder DSL is worth writing

Next up: the SwiftUI you can see — animation, transitions, and gestures.

How well do you know this now? Rating yourself honestly, then being tested on it, is how you find out where your intuition is wrong.