April 26, 2021·6 min read·engineeringswiftresult-builderslanguage-design

Swift 5.4 gives result builders a defined language model

Swift 5.4 formalizes the transformation behind result builders. Static builder methods determine how expressions, branches and loops contribute to a result, with deliberate limits on inference and control flow.

An ordinary Swift function ignores the values of expression statements unless the program uses them. A result builder gives those expressions another meaning by collecting them into the function's result. Swift 5.4, released on April 26, makes result builders an official language feature. The important change is a defined transformation that library authors can use to construct their own structured interfaces.

The feature already has a history. SE-0289 describes its earlier, hidden implementation under the name function builders since Swift 5.1. SwiftUI is its best-known application. The accepted design explains the mechanism independently of a particular UI framework, including which statements it can transform and how a builder supplies the operations the compiler calls.

I find this a useful direction for Swift's library design. Structured declarations can remove repetitive container syntax while retaining ordinary typed values. Understanding the transformation matters to that judgement. A short declaration remains understandable only if its author can explain what the compiler does with each expression and branch.

The compiler collects intermediate values

A result builder type carries the @resultBuilder attribute and supplies at least one static buildBlock method. The compiler transforms eligible function bodies into intermediate bindings and calls to the builder's static methods. Those calls use ordinary type checking and overload resolution. The builder provides library code that decides how the intermediate values combine.

The distinction between language transformation and library behavior is useful. Swift defines where partial results come from and which named methods receive them. The library chooses what those results represent. They might be nodes in a tree, elements in a list or another structured value. The language does not require every builder to return an array or a tuple.

Here is a complete small builder for a list of strings. It uses the methods described by SE-0289 and produces a plain array as its final result.

@resultBuilder
struct Lines {
    static func buildExpression(_ value: String) -> [String] {
        [value]
    }

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

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

@Lines
func labels(includeDetail: Bool) -> [String] {
    "Name"
    if includeDetail {
        "Description"
    }
}

Each string expression becomes an array through buildExpression. At each statement block, buildBlock combines its partial results. The conditional branch produces an optional partial result, which buildOptional converts into an array. Calling labels with a false argument therefore produces an array containing only the first string.

The example deliberately uses the same intermediate type throughout. Real builders can distinguish the expression type, the intermediate component and the final result. An overloaded buildExpression can accept several kinds of source expression and convert them into one representation. A buildFinalResult method can convert the outermost combined component into the public return type.

Control flow needs matching builder operations

The presence of particular methods determines which statement forms a builder supports. An if without an else needs a way to represent the missing branch. In the example, an empty array provides that representation. Another builder could retain optional structure in the result instead of flattening it away.

An if with an else uses buildEither(first:) and buildEither(second:). These methods distinguish the selected path while allowing its result to enter the enclosing block. The same operations support a switch with several result-producing cases. A builder can preserve branch-specific type information or convert both cases into a common component, depending on its method signatures.

A for loop uses buildArray. The transformation executes the iterations and collects their partial results into an array before calling that method. Defining a method named buildArray therefore provides the combining operation for iteration results. It does not turn an ordinary loop into a new execution or scheduling mechanism.

These rules explain why a particular builder may reject code that looks like valid Swift control flow. The language syntax is only part of the contract. The builder must provide the corresponding operation with types that work for the values being produced. A list builder and a heterogeneous tree builder may intentionally support different sets of statements.

SE-0289 also limits imperative control flow. A transformed function cannot contain arbitrary return, break or continue statements. The proposal treats guard as provisionally unsupported. Handling skipped partial results would require additional transformation rules, so accepting the feature does not mean accepting every ordinary function body within it.

Local declarations keep ordinary meaning

Local declarations remain unchanged by the transformation. A let binding can name an intermediate value for clarity, and later expressions can use that value. The binding itself does not automatically become a component in the result. This preserves a distinction between preparing a value and contributing a value to the structure under construction.

Assignments need more care because they are expressions returning Void. A builder can define a buildExpression overload for that type if it wants to accept such statements. The behavior then belongs to that builder. Code that mutates local state inside a declaration language is still performing mutation, even when the surrounding layout resembles a static data structure.

The proposal leaves throw statements unchanged. It disallows defer and do statements with catch blocks in transformed bodies. A plain do block can contribute a combined result. These distinctions make the transformation narrower than a general facility for rewriting Swift programs, and they give library authors specific rules to document.

The runtime behavior of ordinary statements is an explicit constraint of the design. Result builders collect values that ordinary statement evaluation would otherwise discard. They do not grant the library unrestricted access to the program's syntax tree. A builder that constructs a representation of work can choose when that represented work later runs, but the expressions constructing the representation still follow the transformation's evaluation rules.

Attributes affect callers and declarations differently

A builder attribute can annotate a function or a getter. In that position it changes implementation processing without becoming part of the function's interface. The proposal also permits the attribute on a parameter whose type is a function. That position tells the compiler to transform an explicit closure supplied by a caller.

The parameter form has a source compatibility consequence. A caller can write multiple expression statements in a closure because the receiving API requests builder processing. Changing the attribute can therefore change whether existing call sites type check. The proposal distinguishes that effect from binary interface changes, which the transformation does not require.

An explicit return inside a closure suppresses the transformation in that case. A developer reading a callback therefore needs to know both the receiving declaration and the closure body. This is an example of contextual language behavior that makes an API more concise while increasing the value of good tooling and precise documentation.

I prefer a builder API that documents its supported statements alongside its accepted values. Showing only the shortest successful example conceals the contract a caller needs when adding a branch or factoring out an expression. The static methods make that contract inspectable, even when call-site syntax contains no explicit calls to those methods.

Inference limits keep the transformation tractable

SE-0289 specifies that expressions first determine their intermediate types independently. Their types do not receive arbitrary backward influence from the later call that combines them. An integer expression and a floating-point expression can therefore infer different types before a generic combining method attempts to use them together.

That constraint is significant for library design. A builder cannot rely on the final result type to resolve every ambiguity in earlier expressions. Suitable buildExpression overloads can provide context locally, while clearer expressions can make the intended type explicit. The proposal relates the inference restriction to unacceptable type-checking performance in earlier implementations that allowed backward propagation.

Swift 5.4's release announcement also describes better result-builder diagnostics. That work matters because errors appear in source that omits the generated intermediate calls. A diagnostic needs to connect a failure in the transformation to the expression or statement the programmer can actually edit. Formalizing the method contracts gives both the compiler and library documentation a concrete account of why a declaration succeeds or fails.

Availability checks add another specific concern for typed builders. A branch guarded by if #available can produce a component whose type refers to an API unavailable outside that branch. The optional buildLimitedAvailability method gives a builder a place to erase such type information before it escapes into the surrounding result. This is useful for builders that encode their structure in generic types. The runtime branch still determines which expression executes, while the method controls what type information the enclosing block retains. A builder author must account for both aspects when supporting conditional use of newer APIs.