What does the compiler do with the statements inside a result builder body, in what order do the builder methods run, and why does it affect type checking?

5.10.2 · L5 · Learning

Predict first

What does this print?

@resultBuilder enum Trace {
    static func buildExpression(_ x: Int) -> [String] { print("expression", x); return ["\(x)"] }
    static func buildOptional(_ x: [String]?) -> [String] { print("optional", x != nil); return x ?? [] }
    static func buildBlock(_ parts: [String]...) -> [String] { print("block", parts.count); return parts.flatMap { $0 } }
}
@Trace func build(_ flag: Bool) -> [String] {
    1
    2
    if flag { 3 }
}
print(build(true))
print(build(false))
  • expression 1 / expression 2 / expression 3 / block 1 / optional true / block 3 / ["1", "2", "3"] and then expression 1 / expression 2 / optional false / block 3 / ["1", "2"]
  • block 3 first, then the expressions
  • optional is never called
  • only the final array is printed

Mental model. A result builder is a recipe card where you only list ingredients; the kitchen (compiler) decides how to fold them into one dish by calling the chef (buildBlock) at the end.

A result builder rewrites a block of statements, with no explicit return, into calls on the builder type. Each expression statement is passed through buildExpression (if defined), an if without else becomes buildOptional (with an if/else it is buildEither(first:)/buildEither(second:)), and the results of the statements are combined by one buildBlock call. Inner blocks are built first. The trace below shows the order for 1; 2; if flag { 3 }: the three expressions, then the inner block for the if body, then buildOptional, then the outer buildBlock with three parts. When the condition is false the inner pieces never run, and buildOptional receives nil.

Two practical consequences. First, the body is type-checked as one nested expression built from these calls, so a large body with many overloaded operators is a classic cause of slow or failed type checking, and why “extract a subview” fixes error messages that point at the wrong line. Second, the builder decides what is allowed: unsupported statements (a for loop without buildArray, a var assignment that the builder does not model) are errors in the body, not runtime failures. SwiftUI’s ViewBuilder is the same mechanism with buildBlock overloads for up to ten children, which is where the old ten-child limit of a stack came from (I did not test the current limit).

Inner pieces are built first, then buildOptional, then the outer buildBlock.

Common traps

  • Putting side effects in the body and assuming they run once. The body can re-run whenever the owner re-evaluates it.
  • Fixing a mystery type error at the line the compiler reports. Split the body into smaller helpers and the error moves to the real cause.

They might follow up with

  • How do you support for loops? Implement buildArray.
  • Is the builder transform visible? Yes, -dump-ast shows the rewritten body.

Related questions

  • 1.5.8 What is a result builder (@ViewBuilder, @CommandsBuilder)?
  • 1.10.3 Macros vs property wrappers vs result builders — one sentence each.
  • 5.6.1 A Swift file takes forever to compile. How do you find the cause and fix it?
  • 5.1.3 How do you look at what the compiler produced at each stage?

← What does the compiler turn a property wrapper into: what storage…   How is an async function different in compiled code, what does await… →