Result Builders in Swift: The Magic Behind SwiftUI's DSL


Every time you write a VStack { Text("Toy Story") } body, Swift is silently transforming those statements into an array of views — you’re not writing a closure that returns a value, you’re writing a mini program in a custom DSL. Once you understand the @resultBuilder mechanism behind it, you can build the same expressive syntax for HTML generation, animation sequencing, route definitions, or any domain where a declarative style beats imperative calls.

This post covers the full @resultBuilder API surface: buildBlock, buildOptional, buildEither, buildArray, and buildExpression. We’ll build two production-quality examples — an HTML builder and an animation sequence builder — and explore where the pattern fits and where it doesn’t. We won’t cover Swift Macros or custom view modifiers, which have their own deep dives.

Contents

The Problem

Before result builders existed (pre-Swift 5.4 / SE-0289), composing multiple values inside a closure required explicit, ceremony-heavy APIs. Imagine assembling a view hierarchy using the raw ViewBuilder API without the syntactic sugar:

// Without result builder syntax — what you'd have to write
let stack = VStack(content: {
    ViewBuilder.buildBlock(
        Text("Toy Story"),
        Image("buzz-lightyear"),
        Text("To infinity and beyond!")
    )
})

For three children that’s already painful. Scale it to a real screen — conditionals, loops, optional content — and the call site becomes unreadable. Now imagine building the same kind of DSL yourself for a domain that isn’t SwiftUI. Without result builders, you’re stuck with arrays or variadic parameters:

// ❌ The old approach: callers must wrap everything in an array
struct PixarPage {
    let elements: [HTMLElement]
}

// Every call site looks like this
let page = PixarPage(elements: [
    HTMLElement.heading("Toy Story"),
    HTMLElement.paragraph("A cowboy doll is threatened..."),
    HTMLElement.image(src: "woody.jpg"),
    HTMLElement.paragraph("Release year: 1995"),
])

This works, but it forces callers to think in terms of array syntax rather than structure. The moment you want optional elements (show a spoiler warning only when showSpoilers is true) you’re back to ternaries or compactMap inside the array literal — the call site becomes logic, not declaration.

How Result Builders Work

A result builder is a type annotated with @resultBuilder that defines a family of static build* methods. The Swift compiler rewrites a closure body annotated with that builder type — transforming each statement into a call to one of those methods.

The minimum viable result builder requires exactly one method:

@resultBuilder
struct SimpleStringBuilder {
    // Called for every sequence of statements
    static func buildBlock(_ components: String...) -> String {
        components.joined(separator: "\n")
    }
}

You can then use this builder as a function parameter attribute or as a function body annotation:

func makeDescription(@SimpleStringBuilder _ content: () -> String) -> String {
    content()
}

let description = makeDescription {
    "Toy Story (1995)"
    "Directed by John Lasseter"
    "The first Pixar feature film."
}
// description == "Toy Story (1995)\nDirected by John Lasseter\nThe first Pixar feature film."

The compiler rewrites that three-statement closure into: SimpleStringBuilder.buildBlock("Toy Story (1995)", "Directed by John Lasseter", "The first Pixar feature film.").

Apple Docs: resultBuilder — Swift Standard Library

Building an HTML DSL

A richer example: generating HTML for Pixar film pages. The goal is to write HTML structure as declarative Swift rather than string concatenation.

Start by defining the builder:

@resultBuilder
struct HTMLBuilder {
    // Required: combines all top-level statements into one output
    static func buildBlock(_ components: String...) -> String {
        components.joined(separator: "\n")
    }

    // Support `if condition { ... }` — component may be nil
    static func buildOptional(_ component: String?) -> String {
        component ?? ""
    }

    // Support `if condition { ... } else { ... }` — first branch
    static func buildEither(first component: String) -> String {
        component
    }

    // Support `if condition { ... } else { ... }` — second branch
    static func buildEither(second component: String) -> String {
        component
    }

    // Support `for item in items { ... }` — array of results
    static func buildArray(_ components: [String]) -> String {
        components.joined(separator: "\n")
    }
}

Now define the HTML element helpers that callers will write inside the builder closure:

func h1(_ text: String) -> String { "<h1>\(text)</h1>" }
func h2(_ text: String) -> String { "<h2>\(text)</h2>" }
func p(_ text: String) -> String { "<p>\(text)</p>" }
func img(src: String, alt: String) -> String {
    "<img src=\"\(src)\" alt=\"\(alt)\" />"
}
func div(@HTMLBuilder content: () -> String) -> String {
    "<div>\n\(content())\n</div>"
}

With those primitives in place, the page builder reads almost like JSX:

struct PixarFilm {
    let title: String
    let year: Int
    let director: String
    let synopsis: String
    let posterURL: String
    let hasPostCreditScene: Bool
}

func filmPage(for film: PixarFilm, @HTMLBuilder content: () -> String) -> String {
    """
    <!DOCTYPE html>
    <html>
    <body>
    \(content())
    </body>
    </html>
    """
}

let toyStory = PixarFilm(
    title: "Toy Story",
    year: 1995,
    director: "John Lasseter",
    synopsis: "A cowboy doll fears being replaced by a new spaceman toy.",
    posterURL: "toy-story-poster.jpg",
    hasPostCreditScene: false
)

let html = filmPage(for: toyStory) {
    div {
        h1(toyStory.title)
        h2("Directed by \(toyStory.director) · \(toyStory.year)")
        img(src: toyStory.posterURL, alt: "\(toyStory.title) poster")
        p(toyStory.synopsis)
        if toyStory.hasPostCreditScene {
            p("⚠️ Stay through the credits!")
        }
    }
}

The if toyStory.hasPostCreditScene block compiles because the builder defines buildOptional. Without it, the compiler would emit: “Closure containing control flow statement cannot be used with result builder”.

Supporting Conditionals and Loops

Let’s make the builder handle a list of characters from multiple films:

struct PixarCharacter {
    let name: String
    let film: String
    let role: String
}

let characters: [PixarCharacter] = [
    PixarCharacter(name: "Woody", film: "Toy Story", role: "Cowboy"),
    PixarCharacter(name: "Buzz Lightyear", film: "Toy Story", role: "Space Ranger"),
    PixarCharacter(name: "Nemo", film: "Finding Nemo", role: "Clownfish"),
    PixarCharacter(name: "Marlin", film: "Finding Nemo", role: "Father"),
    PixarCharacter(name: "WALL-E", film: "WALL-E", role: "Waste Collector"),
]

func characterSection(showRoles: Bool, @HTMLBuilder content: () -> String) -> String {
    "<section>\n\(content())\n</section>"
}

let section = characterSection(showRoles: true) {
    h2("Pixar Characters")
    for character in characters {
        div {
            h2(character.name)
            p("Film: \(character.film)")
            if showRoles {
                p("Role: \(character.role)")
            }
        }
    }
}

The for loop works because of buildArray. The compiler transforms the loop body into an array of String values, then passes that array to buildArray. The if showRoles block inside the loop uses buildOptional because conditional content without an else may produce nothing.

Note: buildArray receives [String] — the type returned by the closure body for each iteration. Make sure the component type you pass to buildArray matches your builder’s component type.

A Second Example: Animation Sequence Builder

Result builders shine any time you have a sequence of typed values that should read as declarations. A character animation pipeline is a perfect fit — each animation step has a type, a duration, and parameters.

// Animation step types
struct FadeIn {
    let duration: TimeInterval
    var description: String { "fade-in(\(duration)s)" }
}

struct Move {
    enum Target { case center, offscreen, origin }
    let to: Target
    let duration: TimeInterval
    var description: String { "move(to:\(to), \(duration)s)" }
}

struct Scale {
    let factor: Double
    let duration: TimeInterval
    var description: String { "scale(\(factor)x, \(duration)s)" }
}

// A type-erased animation step
struct AnyAnimationStep {
    let description: String
    let duration: TimeInterval
}

extension FadeIn {
    var asStep: AnyAnimationStep {
        AnyAnimationStep(description: description, duration: duration)
    }
}
extension Move {
    var asStep: AnyAnimationStep {
        AnyAnimationStep(description: description, duration: duration)
    }
}
extension Scale {
    var asStep: AnyAnimationStep {
        AnyAnimationStep(description: description, duration: duration)
    }
}

Now the builder. Notice buildExpression — it converts each concrete animation type into the common AnyAnimationStep component type:

@resultBuilder
struct AnimationSequenceBuilder {
    // Convert each concrete step type into the component type
    static func buildExpression(_ expression: FadeIn) -> AnyAnimationStep {
        expression.asStep
    }
    static func buildExpression(_ expression: Move) -> AnyAnimationStep {
        expression.asStep
    }
    static func buildExpression(_ expression: Scale) -> AnyAnimationStep {
        expression.asStep
    }

    // Collect all steps into a sequence
    static func buildBlock(_ components: AnyAnimationStep...) -> [AnyAnimationStep] {
        Array(components)
    }

    static func buildOptional(_ component: [AnyAnimationStep]?) -> [AnyAnimationStep] {
        component ?? []
    }

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

struct AnimationSequence {
    let steps: [AnyAnimationStep]
    var totalDuration: TimeInterval { steps.reduce(0) { $0 + $1.duration } }

    init(@AnimationSequenceBuilder _ build: () -> [AnyAnimationStep]) {
        self.steps = build()
    }
}

The call site is exactly as clean as SwiftUI:

let woodyEntrance = AnimationSequence {
    FadeIn(duration: 0.3)
    Move(to: .center, duration: 0.5)
    Scale(factor: 1.2, duration: 0.2)
    Scale(factor: 1.0, duration: 0.1)
}

print("Woody entrance: \(woodyEntrance.totalDuration)s total")
// Woody entrance: 1.1s total

let buzzEntrance = AnimationSequence {
    FadeIn(duration: 0.2)
    for _ in 0..<3 {
        Scale(factor: 1.1, duration: 0.1)
        Scale(factor: 1.0, duration: 0.1)
    }
    Move(to: .center, duration: 0.4)
}

buildExpression is the key difference from the HTML example: because our domain has multiple distinct input types (FadeIn, Move, Scale) that need to be unified into a single component type (AnyAnimationStep), we define one overload per input type. The compiler picks the right overload at each expression statement.

Advanced Usage

buildFinalResult

By default, buildBlock returns the component type directly. If you want a different public API type, define buildFinalResult to transform the accumulated result at the call site boundary:

@resultBuilder
struct PixarPlaylistBuilder {
    static func buildBlock(_ titles: String...) -> [String] {
        Array(titles)
    }

    // Transform [String] into a named struct at the call site boundary
    static func buildFinalResult(_ components: [String]) -> Playlist {
        Playlist(titles: components)
    }
}

struct Playlist {
    let titles: [String]
}

Variadic vs. Single-Argument buildBlock

Swift allows you to overload buildBlock for specific arities — useful if you want tuple-typed outputs rather than arrays. SwiftUI uses this trick to get compile-time-checked TupleView types (up to 10 children). For most custom DSLs, the variadic form is sufficient.

Using Result Builders on Methods

You can apply a result builder to a stored property’s initializer closure or a method parameter, not just freestanding functions:

struct FilmCatalog {
    private(set) var films: [PixarFilm]

    init(@FilmBuilder _ build: () -> [PixarFilm]) {
        self.films = build()
    }
}

Warning: Result builders are a compile-time transformation — the closure body is rewritten by the compiler before it runs. You cannot use return statements, break, continue, or throw inside a result builder closure unless the builder explicitly handles them via buildLimitedAvailability or dedicated buildBlock overloads. Attempting to use return in a builder closure produces a compiler error.

buildLimitedAvailability

To support #available checks inside a builder closure, implement buildLimitedAvailability:

static func buildLimitedAvailability(_ component: String) -> String {
    component
}

Without this, the compiler refuses if #available(iOS 17, *) { ... } inside your builder closure.

Performance Considerations

Result builders are a compile-time feature. The build* method calls are inlined by the compiler — there is no runtime dispatch or dynamic allocation from the builder mechanism itself. The performance profile of your DSL is entirely determined by the types you produce.

In the HTML example, every expression allocates a String. In the animation example, every step allocates an AnyAnimationStep. For hot paths this matters, but for UI construction and configuration (the dominant use cases) it’s irrelevant.

The compile-time cost, however, is real. Complex result builder closures with many branches measurably increase Swift’s type-checker time. SwiftUI itself has historically been a source of slow compilation for large body implementations — the same applies to your custom builders with deeply nested or highly conditional content.

Apple Docs: SE-0289: Result Builders — Swift Evolution Proposal

When to Use (and When Not To)

ScenarioRecommendation
Building declarative UI configuration (like SwiftUI)Strong fit — result builders were designed for this
HTML/XML/DSL generation with mixed typesGood fit — use buildExpression to unify types
Sequential typed pipelines (animations, routes, middleware)Good fit — the call site reads as a clear sequence
Simple collections of homogeneous valuesPrefer arrays or variadic parameters — simpler
Branching logic that is genuinely complexAvoid — deeply nested buildEither chains are hard to debug
APIs consumed by beginners or auto-generated codeAvoid — the magic is helpful for humans, confusing for tooling
Performance-critical inner loopsAvoid — prefer explicit data structures

Summary

  • A result builder is a type annotated with @resultBuilder that defines static build* methods. The compiler rewrites annotated closure bodies into calls to those methods.
  • buildBlock is the only required method. buildOptional, buildEither, buildArray, and buildExpression unlock progressively more expressive closure syntax.
  • buildExpression is the right tool when your DSL accepts multiple distinct input types — define one overload per type to unify them into a common component.
  • buildFinalResult lets you separate the builder’s internal accumulation type from the public return type.
  • The performance cost is at compile time, not runtime — result builders produce the same code as manual calls to buildBlock.

Now that you understand the transformation mechanism, Swift Macros are the natural next step — they operate on the syntax tree directly and can generate the boilerplate that result builders still require you to write by hand.