Home

Writing a result builder, and when not to

@ViewBuilder is not magic in SwiftUI; it is a general language feature, and you can build your own. The mechanism took me an afternoon to understand and it is much smaller than the SwiftUI code that uses it suggests.

The minimum

A result builder is a type with static methods that the compiler calls to combine statements.

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: " ")
    }
}

@StringBuilder
func greeting(name: String) -> String {
    "Hello,"
    name
    "welcome back."
}

greeting(name: "Tuan")     // "Hello, Tuan welcome back."

buildBlock is the only required method. The compiler rewrites the function body into a call to it, passing each statement as an argument.

The methods you add as you need them

Each control-flow feature needs a method to support it. Omit one and that syntax is a compile error inside the builder — which is why for loops did not work in @ViewBuilder for years.

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ parts: String...) -> String {
        parts.joined(separator: " ")
    }

    static func buildOptional(_ part: String?) -> String {
        part ?? ""                                    // enables `if` with no `else`
    }

    static func buildEither(first: String) -> String { first }
    static func buildEither(second: String) -> String { second }
                                                      // enables `if`/`else`

    static func buildArray(_ parts: [String]) -> String {
        parts.joined(separator: " ")                  // enables `for`
    }

    static func buildExpression(_ part: String) -> String { part }
                                                      // transforms each expression
    static func buildFinalResult(_ part: String) -> String {
        part.trimmingCharacters(in: .whitespaces)     // runs once at the end
    }
}

buildExpression is the interesting one: it lets you accept different types and normalise them. That is how @ViewBuilder accepts a Text, an Image and a custom view in the same block.

A real one: a query builder

The case where I actually wanted this — building SQL fragments with a readable syntax:

@resultBuilder
struct PredicateBuilder {
    static func buildBlock(_ conditions: Condition...) -> Condition {
        .and(conditions)
    }

    static func buildOptional(_ condition: Condition?) -> Condition {
        condition ?? .always
    }

    static func buildEither(first: Condition) -> Condition { first }
    static func buildEither(second: Condition) -> Condition { second }
}

func query(@PredicateBuilder _ build: () -> Condition) -> Query {
    Query(condition: build())
}

Used like this:

let results = query {
    Column("status") == "active"
    Column("created_at") > startDate

    if let author {
        Column("author_id") == author.id
    }
}

The if is what makes it worth the machinery. Without a builder, an optional condition means constructing an array and conditionally appending, which is three times the code and reads as plumbing rather than as a query.

Warning

Error messages inside a result builder are consistently bad. A type mismatch on line four reports against the whole block, and a missing buildArray says “closure containing a control flow statement cannot be used with result builder” rather than naming the loop. Budget for this — it is the main cost of the feature.

When not to write one

I want to be direct about this, because result builders are fun to write and mostly unnecessary.

If an array literal works, use an array literal. [.name, .email, .age] is clearer than a builder producing the same thing, and it needs no explanation to a reader.

If there is no control flow, there is no point. The entire value of a builder over an array is if, for and switch inside the literal. A builder that only concatenates is ceremony.

If it is used in one place, a function taking an array is better. The builder’s cost is a type nobody else has seen before.

The test I use: would this block contain an if or a for at least half the time it is written? For @ViewBuilder the answer is obviously yes — conditional views are the normal case. For a list of configuration options, it is no, and an array is the honest shape.

Where they earn it

Declarative UI — the original case.

Query and predicate DSLs, as above, where conditions are frequently optional.

Test fixtures with conditional setup.

Anything where the alternative is a mutable array and a series of append calls, because that is exactly the pattern a builder replaces, and the builder version cannot be written wrong by appending in the wrong order.

That last one is the clearest signal. If you find yourself writing:

var conditions: [Condition] = []
conditions.append(.status("active"))
if let author { conditions.append(.author(author.id)) }

…that is a result builder waiting to happen. If you do not find yourself writing that, it is not.