← All lessonsGuide · Result builders, in full
Guide

Result builders, in full

Every method a builder can have — the statement that calls it, what it receives, the call written out — and what SwiftUI's own builder leaves out

Lesson 4 builds WidgetBuilder with the methods TinyUI needs. A result builder can have more, and this guide goes through all of them using one builder, Words, which turns a block of words into a sentence. For each method: the statement that makes the compiler call it, what it receives and what it returns, and the same call written out by hand, run side by side with the sugar. Partway through, a look inside the compiler itself: what it prints when it has rewritten a for loop, the C++ that does the rewriting, and what is left of it in SIL. After that comes SwiftUI's own builder — which of the methods it has, which it leaves out, and why a for loop is the one statement a SwiftUI body cannot hold. Every piece of code on this page was compiled and run on the iOS 27 simulator, and every output, error and note shown is what that run really gave.

1 · from lesson 4

The builder, and the statement behind each method

A result builder is any type marked @resultBuilder. Its methods are static, and the compiler finds them by name — there is no protocol to conform to, just as there is none for property wrappers (lesson 2). The value the methods pass between them is called the builder's component. For Words it is [String]: the words so far.

Words: one builder with every kind of method
@resultBuilder
enum Words {
    static func buildBlock(_ parts: [String]...) -> [String] {
        parts.flatMap { $0 }
    }

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

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

    static func buildOptional(_ part: [String]?) -> [String] {
        part ?? []
    }

    static func buildEither(first: [String]) -> [String] {
        first
    }

    static func buildEither(second: [String]) -> [String] {
        second
    }

    static func buildArray(_ parts: [[String]]) -> [String] {
        parts.flatMap { $0 }
    }

    static func buildLimitedAvailability(_ part: [String]) -> [String] {
        part
    }

    static func buildFinalResult(_ parts: [String]) -> String {
        parts.joined(separator: " ")
    }
}
You writeThe compiler callsIn Words
An expression on a line of its ownbuildExpressiona String or an Int becomes [String]
Several statements in one block — any { }buildBlock, once for the block (or buildPartialBlock, one statement at a time)joins the parts
if without else, and if letbuildOptionalnothing becomes []
if/else, and switchbuildEither(first:) or buildEither(second:)passes the branch on
for … inbuildArray, once, after the loopjoins every pass
if #availablebuildLimitedAvailabilitypasses it on
Nothing — it runs once, at the very endbuildFinalResultturns [String] into one String

Mark a function with the builder's name and its body is built with it. This one uses every method:

A sentence, built
@Words
func sentence(excited: Bool, nickname: String?, places: [String]) -> String {
    "Pip"
    if let nickname {
        "(\(nickname))"
    }
    "went"
    for place in places {
        "to"
        place
    }
    3
    "times"
    if excited {
        "!"
    } else {
        "."
    }
    if #available(iOS 27, *) {
        "today"
    }
}

Called once with everything and once with nothing optional, it prints

Console
Pip (Biscuit) went to Seattle to Victoria 3 times ! today
Pip went 3 times . today
2 · from lesson 4

Every call, in order

To watch the calls, give each method a print. Traced is Words with one added line in every method (and without the Int overload, which the block below does not need):

Traced: Words, printing every call
@resultBuilder
enum Traced {
    static func buildBlock(_ parts: [String]...) -> [String] {
        print("buildBlock(\(parts))")
        return parts.flatMap { $0 }
    }

    static func buildExpression(_ word: String) -> [String] {
        print("buildExpression(\"\(word)\")")
        return [word]
    }

    static func buildOptional(_ part: [String]?) -> [String] {
        print("buildOptional(\(part.map { "\($0)" } ?? "nil"))")
        return part ?? []
    }

    static func buildEither(first: [String]) -> [String] {
        print("buildEither(first: \(first))")
        return first
    }

    static func buildEither(second: [String]) -> [String] {
        print("buildEither(second: \(second))")
        return second
    }

    static func buildArray(_ parts: [[String]]) -> [String] {
        print("buildArray(\(parts))")
        return parts.flatMap { $0 }
    }

    static func buildLimitedAvailability(_ part: [String]) -> [String] {
        print("buildLimitedAvailability(\(part))")
        return part
    }

    static func buildFinalResult(_ parts: [String]) -> String {
        print("buildFinalResult(\(parts))")
        return parts.joined(separator: " ")
    }
}
A block with one statement of each kind
@Traced
func tracedSentence(excited: Bool) -> String {
    "Pip"
    if excited {
        "!"
    }
    for place in ["Seattle", "Victoria"] {
        place
    }
    if #available(iOS 27, *) {
        "today"
    } else {
        "some day"
    }
}

tracedSentence(excited: false) prints

Console
buildExpression("Pip")
buildOptional(nil)
buildExpression("Seattle")
buildBlock([["Seattle"]])
buildExpression("Victoria")
buildBlock([["Victoria"]])
buildArray([["Seattle"], ["Victoria"]])
buildExpression("today")
buildBlock([["today"]])
buildLimitedAvailability(["today"])
buildEither(first: ["today"])
buildBlock([["Pip"], [], ["Seattle", "Victoria"], ["today"]])
buildFinalResult(["Pip", "Seattle", "Victoria", "today"])
= Pip Seattle Victoria today

Read it from the top:

  1. Each expression goes through buildExpression when the code reaches it.

  2. The if is false, so buildOptional receives nil — and "!" never reached buildExpression. The inside of a false if is not run at all.

  3. Each pass through the loop is a block of its own, so buildBlock runs once per pass. buildArray runs once, when the loop has finished, with every pass in hand.

  4. The #available branch is a block too; its result then goes through buildLimitedAvailability, and only then through buildEither.

  5. The function's own block is combined last, once everything in it is ready — and buildFinalResult comes after that, once.

3 · from lesson 4

buildExpression — every value, first

Every expression that stands on a line of its own is handed to buildExpression before anything else happens to it, and what comes back is a component. This is where a builder decides which values it accepts. Words has two overloads, one for String and one for Int, so a number is welcome in its blocks:

The sugar
@Words
func count() -> String {
    "Pip went"
    3
    "times"
}
Written out
func countByHand() -> String {
    Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went"),
            Words.buildExpression(3),
            Words.buildExpression("times")
        )
    )
}

Both print

Console
Pip went 3 times
Pip went 3 times

Without an overload for a value's type, the value is refused. Plain accepts String only:

Plain
@resultBuilder
enum Plain {
    static func buildBlock(_ parts: [String]...) -> [String] {
        parts.flatMap { $0 }
    }

    static func buildExpression(_ word: String) -> [String] {
        [word]
    }
}
A number in a Plain block
@Plain
func countPlainly() -> [String] {
    "Pip went"
    3
    "times"
}

It does not compile

cannot convert value of type 'Int' to expected argument type 'String'

buildExpression is optional. Without it, each expression must already be a component, and goes to buildBlock exactly as it is. SwiftUI's builder has none — every line of a body goes to its buildBlock as it stands.

4 · from lesson 4

buildBlock — one call per block

buildBlock receives all the components of one block at once, in order — which is why Words declares it variadic, [String].... It is called once for every block: the function's own body, each branch of an if, each pass through a loop. In the trace in Every call, in order it ran four times: once for each of the loop's two passes, once for the #available branch, and last of all once for the function itself.

An empty block calls it with no arguments. A variadic buildBlock covers that case for free — Words.buildBlock() is [] — while SwiftUI declares a separate buildBlock() for it, which returns EmptyContent (see SwiftUI's builder, below).

5 · from lesson 4

buildPartialBlock — the same job, two at a time

Instead of buildBlock, a builder can declare a pair of methods that combine a block one statement at a time: buildPartialBlock(first:) for the first component, and buildPartialBlock(accumulated:next:) to fold in each one after it. Lesson 4's WidgetBuilder uses this pair, because each step returns a bigger type — Column<A, B> — and a plain variadic method cannot do that: [String]... needs every part to be the same type. (SwiftUI's buildBlock gets round that another way, with parameter packs — repeat each Content.)

A builder may declare both. The compiler then uses the pair:

Both, with buildBlock and the pair
@resultBuilder
enum Both {
    static func buildBlock(_ parts: String...) -> String {
        print("buildBlock(\(parts))")
        return parts.joined(separator: " ")
    }

    static func buildPartialBlock(first: String) -> String {
        print("buildPartialBlock(first: \"\(first)\")")
        return first
    }

    static func buildPartialBlock(accumulated: String, next: String) -> String {
        print("buildPartialBlock(accumulated: \"\(accumulated)\", next: \"\(next)\")")
        return accumulated + " " + next
    }
}

@Both
func three() -> String {
    "Pip"
    "went"
    "home"
}

three() prints

Console
buildPartialBlock(first: "Pip")
buildPartialBlock(accumulated: "Pip", next: "went")
buildPartialBlock(accumulated: "Pip went", next: "home")
= Pip went home

SE-0348, the proposal that added the pair, says the transform will "always prefer buildPartialBlock to buildBlock when they are both defined".

6 · from lesson 4

buildOptional — if without else, and if let

An if without an else either produces a component or it does not. If the condition holds, its block is built and buildOptional receives the result; if not, buildOptional receives nil. if let works the same way.

The sugar
@Words
func greeting(nickname: String?) -> String {
    "Pip"
    if let nickname {
        nickname
    }
}
Written out
func greetingByHand(nickname: String?) -> String {
    var middle: [String]? = nil
    if let nickname {
        middle = Words.buildBlock(Words.buildExpression(nickname))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip"),
            Words.buildOptional(middle)
        )
    )
}

With a nickname and without, both print

Console
Pip Biscuit | Pip Biscuit
Pip | Pip

The place for the optional part is always there, filled or empty. In SwiftUI that place is an Optional<Text> in the view's type (lesson 4, step 10), which is how a view keeps its position while a condition changes.

SwiftUI's builder spells this method buildIf, the older name. The compiler still looks for it: its source says "Use buildOptional(_:) if available, otherwise fall back to buildIf".

Apple's documentation of ForEach adds a warning about this method. An if without else in a row means that row produces one view or none, and lazy containers such as List are fastest when every row produces the same number. The fix it gives is to wrap the if in a VStack, and the launch argument -LogForEachSlowPath YES makes SwiftUI log the rows that do not.

7 · from lesson 4

buildEither — if/else and switch

With an else, exactly one branch runs. Its block's result goes to buildEither(first:) if it was the first branch, and to buildEither(second:) if it was the second — so both must end up as the same type.

The sugar
@Words
func ending(excited: Bool) -> String {
    "Pip went home"
    if excited {
        "!"
    } else {
        "."
    }
}
Written out
func endingByHand(excited: Bool) -> String {
    let last: [String]
    if excited {
        last = Words.buildEither(first: Words.buildBlock(Words.buildExpression("!")))
    } else {
        last = Words.buildEither(second: Words.buildBlock(Words.buildExpression(".")))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went home"),
            last
        )
    )
}

Excited and not, both print

Console
Pip went home ! | Pip went home !
Pip went home . | Pip went home .

There are only two methods, so a switch with three cases — or an if/else if/else — is built as a tree of two-way choices. Traced shows its shape:

Three cases
@Traced
func mood(_ n: Int) -> String {
    switch n {
    case 0:
        "calm"
    case 1:
        "busy"
    default:
        "sleepy"
    }
}

Each case, traced

Console
-- mood(0)
buildExpression("calm")
buildBlock([["calm"]])
buildEither(first: ["calm"])
buildEither(first: ["calm"])
buildBlock([["calm"]])
buildFinalResult(["calm"])
= calm
-- mood(1)
buildExpression("busy")
buildBlock([["busy"]])
buildEither(second: ["busy"])
buildEither(first: ["busy"])
buildBlock([["busy"]])
buildFinalResult(["busy"])
= busy
-- mood(2)
buildExpression("sleepy")
buildBlock([["sleepy"]])
buildEither(second: ["sleepy"])
buildBlock([["sleepy"]])
buildFinalResult(["sleepy"])
= sleepy

"calm" is the first of the first, "busy" the second of the first, "sleepy" the second. SE-0289 calls it a "balanced binary tree" of injections. SwiftUI's buildEither returns _ConditionalContent<TrueContent, FalseContent>, and three branches nest into it in exactly this shape — lesson 4's last edge case shows the type.

8 · from lesson 4

buildArray — for … in

The body of a loop is a block, built once per pass. Each pass's result is kept, and when the loop has finished, buildArray receives them all as one array.

The sugar
@Words
func route(_ places: [String]) -> String {
    "Pip went"
    for place in places {
        "to"
        place
    }
}
Written out
func routeByHand(_ places: [String]) -> String {
    var passes: [[String]] = []
    for place in places {
        passes.append(Words.buildBlock(Words.buildExpression("to"), Words.buildExpression(place)))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went"),
            Words.buildArray(passes)
        )
    )
}

Both print

Console
Pip went to Seattle to Victoria
Pip went to Seattle to Victoria

Note when buildArray is called: after the loop. By then every pass has run and everything it made already exists. That timing is half of the reason SwiftUI's builder has no buildArray — see Why ViewBuilder has no buildArray, at the end.

Leave buildArray out, and the compiler says which method to add:

A loop in a Plain block
@Plain
func placesPlainly() -> [String] {
    "Pip went"
    for place in ["to", "Seattle"] {
        place
    }
}

It does not compile

closure containing control flow statement cannot be used with result builder 'Plain'
note: add 'buildArray(_:)' to the result builder 'Plain' to add support for 'for'..'in' loops
9 · from lesson 4

Inside the compiler: how a for loop is taken apart

None of this happens while your program runs. The compiler does the rewriting — in its type checker, written in C++ — before your code becomes anything else, and its -print-ast option prints a function as it was rewritten. Here is route from the section above:

What the compiler made of route (shortened: its @_hasInitialValue markers are left out)
@Words @MainActor @preconcurrency internal func route(_ places: [String]) -> String {
  var $__builder0: [String] = Words.buildExpression("Pip went")

  var $__builder1: [String]

  do {
    var $__builder2: [[String]] = []

    for place in places {
      var $__builder3: [String] = Words.buildExpression("to")

      var $__builder4: [String] = Words.buildExpression(place)

      $__builder2.append(Words.buildBlock($__builder3, $__builder4))
    }
    $__builder1 = Words.buildArray($__builder2)
  }
  return Words.buildFinalResult(Words.buildBlock($__builder0, $__builder1))
}

The loop is still there, but taken apart. Before it, a hidden array, [[String]], with room for one component per pass. Inside it, the body's two statements go through buildExpression, buildBlock combines them, and the result is appended to the array — the body itself no longer produces anything. After it, buildArray receives the whole array at once. Last, the function's own block, and buildFinalResult.

The code that does this is visitForEachStmt, in the compiler's lib/Sema/BuilderTransform.cpp. Its own comment draws the shape it produces, and its first lines are where the "add 'buildArray(_:)'" note above comes from:

From the Swift compiler's source, lib/Sema/BuilderTransform.cpp (shortened) — not code to type
  /// do {
  ///   var $__forEach = []
  ///   for ... in ... {
  ///     ...
  ///     $__builderVar = buildBlock(...)
  ///     $__forEach.append($__builderVar)
  ///   }
  ///   buildArray($__forEach)
  /// }
  NullablePtr<Stmt> visitForEachStmt(ForEachStmt *forEachStmt,
                                     NullablePtr<VarDecl> forEachVar) {
    // for...in statements are handled via buildArray(_:); bail out if the
    // builder does not support it.
    if (!builder.supports(ctx.Id_buildArray))
      return failTransform(forEachStmt);
    …
    SourceLoc startLoc = forEachStmt->getStartLoc();
    SourceLoc endLoc = forEachStmt->getEndLoc();
    …
      // Form a call to Array.append(_:) to add the result of executing each
      // iteration of the loop body to the array formed above.
      {
        auto arrayVarRef = builder.buildVarRef(arrayVar, endLoc);
        auto arrayAppendRef = new (ctx) UnresolvedDotExpr(
            arrayVarRef, endLoc, DeclNameRef(ctx.getIdentifier("append")),
            DeclNameLoc(endLoc), /*implicit=*/true);
    …
      // $__forEach = buildArray($__arrayVar)
      doBody.push_back(buildAssignment(
          forEachVar.get(),
          builder.buildCall(forEachStmt->getEndLoc(), ctx.Id_buildArray,
                            {builder.buildVarRef(arrayVar, endLoc)},
                            {Identifier()})));
    …
    return DoStmt::createImplicit(ctx, LabeledStmtInfo(), doBody);
  }

Between those two pieces it works in syntax trees, not text: it creates the empty array (ArrayExpr::create), rewrites the loop's body as a block of its own (transform(forEachStmt->getBody(), …)), adds a call to the array's append at the end of that body (UnresolvedDotExpr(…, "append")), and after the loop assigns the result of buildArray — all inside an implicit do { }.

By the time the compiler lowers the function to SIL, its intermediate language, there is no builder left — only a loop and function calls. And SIL remembers which line of your source each instruction came from. Here is route line by line, each line followed by the functions that its instructions call:

route in SIL: each source line, and what the instructions charged to it call

func route(_ places: [String]) -> String {
    → static Words.buildBlock(_:)
    → static Words.buildFinalResult(_:)
    "Pip went"
        → String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
        → static Words.buildExpression(_:)
        → _allocateUninitializedArray<A>(_:)
        → _finalizeUninitializedArray<A>(_:)
    for place in places {
        → _allocateUninitializedArray<A>(_:)
        → Collection<>.makeIterator()
        → IndexingIterator.next()
        → static Words.buildBlock(_:)
        "to"
            → String.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:)
            → static Words.buildExpression(_:)
            → _allocateUninitializedArray<A>(_:)
            → _finalizeUninitializedArray<A>(_:)
        place
            → static Words.buildExpression(_:)
    }
        → Array.append(_:)
        → static Words.buildArray(_:)
}

Read it from the top. The function's first line carries its own buildBlock and buildFinalResult — the last things to run. for place in places { became the hidden array, the iterator (makeIterator, then next() on every pass) and the buildBlock that closes each pass. "to" and place became buildExpression calls. (The _allocateUninitializedArray and _finalizeUninitializedArray under "Pip went" and "to" build the array in which a variadic buildBlock receives its arguments.) And the loop's closing brace, a line with no code on it at all, became the append and the buildArray: exactly where the C++ above put them, at endLoc, the end of the loop. The loop has been taken apart, and its pieces filed under the lines that made them. To SIL, buildArray is just one more call to a static function.

10 · from lesson 4

buildLimitedAvailability — if #available

An if #available branch is built like any other if, with one extra step: its block's result goes through buildLimitedAvailability before it reaches buildEither or buildOptional. The reason is the type. A branch may use a type that does not exist on older systems, and if that type became part of the block's result type, the function could not be compiled for them at all. This method is the builder's chance to hide it.

The sugar
@Words
func when() -> String {
    "Pip went"
    if #available(iOS 27, *) {
        "today"
    } else {
        "some day"
    }
}
Written out
func whenByHand() -> String {
    let part: [String]
    if #available(iOS 27, *) {
        part = Words.buildEither(first: Words.buildLimitedAvailability(Words.buildBlock(Words.buildExpression("today"))))
    } else {
        part = Words.buildEither(second: Words.buildBlock(Words.buildExpression("some day")))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went"),
            part
        )
    )
}

Both print

Console
Pip went today
Pip went today

Words has nothing to hide — both branches are [String] — so it passes the value on. SwiftUI's version returns AnyView: it erases the type, the one place where SwiftUI puts an AnyView into your view without being asked. Lesson 4's edge case 7 shows it happening.

11 · from lesson 4

buildFinalResult — once, at the end

Every other method passes components around. buildFinalResult is called once, on the outermost block's result, and whatever it returns is what the function returns — which may be another type. Words works in [String] and finishes with one String, so every function built with it returns String. Declare one as returning [String], and the compiler points at the mismatch:

The sugar
@Words
func listed() -> [String] {
    "Pip"
    "went"
}

It does not compile

cannot convert return expression of type 'String' to return type '[String]'

Without buildFinalResult, the outermost block's component is returned as it is. SwiftUI's builder has none: a body returns exactly what its block built.

12 · from lesson 4

Where the attribute goes

The builder's name is an attribute that switches the transformation on for one body. Besides a function, it can go on a computed property, on a closure parameter, and on a protocol requirement:

The other three places
@Words
var greetingLine: String {
    "Hello"
    "Pip"
}

func say(@Words _ make: () -> String) -> String {
    make()
}

protocol Speaker {
    @Words var line: String { get }
}

struct Dog: Speaker {
    var line: String {
        "woof"
        2
    }
}

Using each of them prints

Console
Hello Pip
Pip sat
woof 2

On a parameter, the attribute applies to the closure the caller writes — that is how VStack { … } works. On a protocol requirement it is inherited: Dog.line carries no attribute, yet it is built with Words. That is why you never write @ViewBuilder on a SwiftUI body: the View protocol's requirement carries it.

13 · from lesson 4

What no builder can take

Methods decide which statements a builder accepts — but some statements no method can make acceptable. Right below visitForEachStmt, the same file lists the statements the transformation has no translation for:

From the Swift compiler's source, lib/Sema/BuilderTransform.cpp — not code to type
  UNSUPPORTED_STMT(Throw)
  UNSUPPORTED_STMT(Return)
  UNSUPPORTED_STMT(Yield)
  UNSUPPORTED_STMT(Then)
  UNSUPPORTED_STMT(Discard)
  UNSUPPORTED_STMT(Defer)
  UNSUPPORTED_STMT(Guard)
  UNSUPPORTED_STMT(While)
  UNSUPPORTED_STMT(DoCatch)
  UNSUPPORTED_STMT(RepeatWhile)
  UNSUPPORTED_STMT(Break)
  UNSUPPORTED_STMT(Continue)

A block that holds one of these cannot be built. Lesson 4's cards and lesson 4's edge cases show what that means in practice. A while loop does not compile, whatever methods the builder has, and neither does do/catch. An explicit return is the odd one: it does not fail on its own — it switches the builder off for the whole block, which is then type-checked as ordinary code. That is why a guard, which must leave with a return, goes wrong in a body (lesson 4, edge case 3). Declarations are a different matter: a let in a builder block is left alone, so let _ = print(…) is the way to put a side effect in a body.

14 · from lesson 4

SwiftUI's builder: what it has, and what it leaves out

SwiftUI's builder is ViewBuilder. Xcode 27 also calls it ContentBuilder — a typealias for the same type, the name reflecting that the builder now builds toolbar items and menu commands as well as views. These are the methods the iOS 27 SDK declares on it, from SwiftUICore's interface, with their bodies and attributes left out:

From the iOS 27 SDK's SwiftUICore interface (shortened) — not code to type
@_functionBuilder public struct ViewBuilder {
    static func buildBlock() -> EmptyContent
    static func buildBlock<Content>(_ content: Content) -> Content
    static func buildBlock<each Content>(_ content: repeat each Content) -> TupleView<(repeat each Content)>
        where repeat each Content : View
    @available(iOS 27.0, *)
    static func buildBlock<each Content>(_ content: repeat each Content) -> TupleContent<repeat each Content>
}

extension ViewBuilder {
    static func buildIf<Content>(_ content: Content?) -> Content?
    static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent>
    static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent>
    static func buildLimitedAvailability<Content>(_ content: Content) -> AnyView
}

public typealias ContentBuilder = ViewBuilder

Note the attribute: @_functionBuilder, not @resultBuilder. That is the underscored name the feature had when SwiftUI shipped it in 2019; SE-0289 made it public as @resultBuilder in Swift 5.4, two years later, and SwiftUI's declaration has kept the old spelling, which the compiler still accepts. Set against the methods above:

MethodWordsLesson 4's WidgetBuilderSwiftUI's ViewBuilder
buildExpressionYesNoNo — each line goes to buildBlock as it stands
buildBlockYesNoYes: for an empty block, one statement, and any number (parameter packs)
buildPartialBlockNoYesNo
buildOptionalYesYesYes, under its older name, buildIf
buildEitherYesYesYes: _ConditionalContent
buildArrayYesYes, until step 8No
buildLimitedAvailabilityYesNoYes: it returns AnyView
buildFinalResultYesNoNo

The SwiftUI module adds more overloads of three of those names — buildBlock, buildIf and buildLimitedAvailability — for toolbar content, menu commands and accessibility rotors. None of them adds a method the table says is missing.

15 · from lesson 4

Why ViewBuilder has no buildArray

What it means. A for loop cannot appear in a body, or in any other @ViewBuilder block:

A for loop in a SwiftUI view
import SwiftUI

struct StopList: View {
    let stops: [String]

    var body: some View {
        VStack(alignment: .leading) {
            for stop in stops {
                Text("- " + stop)
            }
        }
    }
}

It does not compile

closure containing control flow statement cannot be used with result builder 'ViewBuilder'

In SwiftUI a loop is a view instead: ForEach, which takes the collection and a closure that makes one row. An if or a switch inside that closure is fine. Lesson 4's LoopEach is the same thing for TinyUI.

It is a choice, not a limit. One more static method would make for work, and SwiftUI's builder for keyframe animations, KeyframeTrackContentBuilder, declares exactly that method — so for is allowed inside a KeyframeTrack:

From the iOS 27 SDK's SwiftUICore interface: KeyframeTrackContentBuilder
public static func buildArray(_ components: [some KeyframeTrackContent<Value>]) -> some KeyframeTrackContent<Value>

Keyframes are made once, in order, and that is all that is ever done with them. Views are not, and the difference explains the omission. Apple's documentation does not give a reason in so many words, but it describes ForEach as a structure that "computes views on demand" from "identified data" — and those are the two things a buildArray cannot give.

On demand. buildArray is called after the loop has finished, with every pass already run — Every call, in order traced it, and Inside the compiler shows the code that does it. So every row would exist, all of them, every time body runs. ForEach keeps the collection and the closure instead, and the container decides which rows to make: a List of a thousand rows makes only the rows on the screen.

Identified. SwiftUI runs body again whenever something it reads changes, and matches each new row to an old one, to know what moved, what was inserted and which row keeps which state. An array from buildArray carries nothing but the rows, so the only thing that could tell two rows apart is their position. ForEach will not even compile without an identity: its elements must be Identifiable, or you pass id:.

Both are run on the simulator — a List of a thousand rows counting what it makes, and a city inserted at the top of a list identified by position and by name — in the guide ForEach, in full.

Flash cards

10 cards, for this guide only: they are not in lesson 4's deck. Answer in your head first, then tap the card.

Syntax · Result buildersWhich statement makes the compiler call which builder method?
@Words
func sentence(excited: Bool, nickname: String?, places: [String]) -> String {
    "Pip"
    if let nickname {
        "(\(nickname))"
    }
    "went"
    for place in places {
        "to"
        place
    }
    3
    "times"
    if excited {
        "!"
    } else {
        "."
    }
    if #available(iOS 27, *) {
        "today"
    }
}

Each expression: buildExpression. Each block: buildBlock. if without else and if let: buildOptional. if/else and switch: buildEither. for: buildArray. if #available: buildLimitedAvailability. Once at the end: buildFinalResult. This function uses them all and printed: Pip (Biscuit) went to Seattle to Victoria 3 times ! today Pip went 3 times . today

Trick question · Result buildersA builder block holds an if whose condition is false. Does the builder ever see the statements inside it?

No. The block inside a false if is not run: buildOptional receives nil, and the "!" never reaches buildExpression. Every call, in order: buildExpression("Pip") buildOptional(nil) buildExpression("Seattle") buildBlock([["Seattle"]]) buildExpression("Victoria") buildBlock([["Victoria"]]) buildArray([["Seattle"], ["Victoria"]]) buildExpression("today") buildBlock([["today"]]) buildLimitedAvailability(["today"]) buildEither(first: ["today"]) buildBlock([["Pip"], [], ["Seattle", "Victoria"], ["today"]]) buildFinalResult(["Pip", "Seattle", "Victoria", "today"]) = Pip Seattle Victoria today

Syntax · Result buildersLet a builder whose components are [String] accept an Int on a line of its own.
func countByHand() -> String {
    Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went"),
            Words.buildExpression(3),
            Words.buildExpression("times")
        )
    )
}

Give it a buildExpression overload for Int. Every expression passes through buildExpression first; the overload for its type turns it into a component. The sugar and the calls written out both printed: Pip went 3 times Pip went 3 times

Trick question · Result buildersA builder declares buildBlock and also the buildPartialBlock pair. Which does the compiler call?

The buildPartialBlock pair — always. buildBlock is never called. SE-0348: the transform will "always prefer buildPartialBlock to buildBlock" when both are defined. It printed: buildPartialBlock(first: "Pip") buildPartialBlock(accumulated: "Pip", next: "went") buildPartialBlock(accumulated: "Pip went", next: "home") = Pip went home

Syntax · Control flow in buildersWrite out by hand what if let nickname { nickname } becomes in a builder block.
func greetingByHand(nickname: String?) -> String {
    var middle: [String]? = nil
    if let nickname {
        middle = Words.buildBlock(Words.buildExpression(nickname))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip"),
            Words.buildOptional(middle)
        )
    )
}

Build the branch's block only if the condition holds, keep nil otherwise, and pass the optional to buildOptional. Sugar and hand-written, with a nickname and without: Pip Biscuit | Pip Biscuit Pip | Pip

Syntax · Control flow in buildersWrite out by hand what an if/else becomes in a builder block.
func endingByHand(excited: Bool) -> String {
    let last: [String]
    if excited {
        last = Words.buildEither(first: Words.buildBlock(Words.buildExpression("!")))
    } else {
        last = Words.buildEither(second: Words.buildBlock(Words.buildExpression(".")))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went home"),
            last
        )
    )
}

Build whichever branch runs, and pass its result to buildEither(first:) for the first branch or buildEither(second:) for the second. Sugar and hand-written: Pip went home ! | Pip went home ! Pip went home . | Pip went home .

Trick question · Control flow in buildersA switch has three cases, and a builder has only buildEither(first:) and buildEither(second:). How is the middle case built?

As a tree of two-way choices. "busy" is wrapped twice: buildEither(second:), then buildEither(first:) around that. Every case, traced: -- mood(0) buildExpression("calm") buildBlock([["calm"]]) buildEither(first: ["calm"]) buildEither(first: ["calm"]) buildBlock([["calm"]]) buildFinalResult(["calm"]) = calm -- mood(1) buildExpression("busy") buildBlock([["busy"]]) buildEither(second: ["busy"]) buildEither(first: ["busy"]) buildBlock([["busy"]]) buildFinalResult(["busy"]) = busy -- mood(2) buildExpression("sleepy") buildBlock([["sleepy"]]) buildEither(second: ["sleepy"]) buildBlock([["sleepy"]]) buildFinalResult(["sleepy"]) = sleepy

Syntax · Control flow in buildersWrite out by hand what a for … in loop becomes in a builder block.
func routeByHand(_ places: [String]) -> String {
    var passes: [[String]] = []
    for place in places {
        passes.append(Words.buildBlock(Words.buildExpression("to"), Words.buildExpression(place)))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went"),
            Words.buildArray(passes)
        )
    )
}

Build the loop body once per pass, keep each result, and when the loop is over pass all of them to buildArray at once. Sugar and hand-written: Pip went to Seattle to Victoria Pip went to Seattle to Victoria

Syntax · Control flow in buildersWrite out by hand what if #available with an else becomes in a builder block.
func whenByHand() -> String {
    let part: [String]
    if #available(iOS 27, *) {
        part = Words.buildEither(first: Words.buildLimitedAvailability(Words.buildBlock(Words.buildExpression("today"))))
    } else {
        part = Words.buildEither(second: Words.buildBlock(Words.buildExpression("some day")))
    }
    return Words.buildFinalResult(
        Words.buildBlock(
            Words.buildExpression("Pip went"),
            part
        )
    )
}

The available branch's result goes through buildLimitedAvailability first, and then through buildEither like any branch. Sugar and hand-written, on iOS 27: Pip went today Pip went today

Syntax · Result buildersBesides a function, name the three places a result builder's attribute can go.
@Words
var greetingLine: String {
    "Hello"
    "Pip"
}

func say(@Words _ make: () -> String) -> String {
    make()
}

protocol Speaker {
    @Words var line: String { get }
}

struct Dog: Speaker {
    var line: String {
        "woof"
        2
    }
}

A computed property; a closure parameter, where it applies to the closure the caller writes; and a protocol requirement, where conforming types inherit it — Dog.line has no attribute and is still built. They printed: Hello Pip Pip sat woof 2

Further reading