Swift Result Builders: Build Custom DSLs and Declarative APIs

Swift result builders let you write multiple statements in a natural, block-based style and have Swift transform them into a single combined value. They are the feature behind many declarative APIs, including SwiftUI-style view composition and other domain-specific languages.

Quick answer: A result builder is a type annotated with @resultBuilder that converts the statements inside a closure into one return value. You use it to make APIs read like a mini language while still producing normal Swift values.

Difficulty: Advanced

Helpful to know first: You'll understand this better if you know Swift closures, structs and enums, and how functions return values.

1. What Is Swift Result Builders?

Result builders are a Swift language feature that changes how a closure body is interpreted. Instead of requiring a single explicit expression, Swift collects the statements inside the closure and passes them through builder methods such as buildBlock, buildEither, and buildOptional.

A result builder is not a new collection type by itself. It is a way to describe how Swift should turn a block of code into a value of your choosing.

2. Why Swift Result Builders Matter

Many APIs are easier to use when you can write them as a block of content instead of assembling arrays or nested function calls by hand. Result builders make those APIs easier to read, easier to extend, and often less verbose.

They matter because they help you:

They are especially useful when the output is a structured result, such as a list of rules, a tree of nodes, or a collection of strings to render later.

3. Basic Syntax or Core Idea

A result builder is defined with the @resultBuilder attribute. The builder type usually provides one or more static methods that combine partial results into the final output.

Minimal builder definition

This example shows the smallest useful shape of a builder that joins strings together.

@resultBuilder
struct StringListBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

The builder above says: “Take one or more String values and combine them into an array.” A function can then use that builder on a closure parameter.

func makeList(@StringListBuilder content: () -> [String]) -> [String] {
    content()
}

When you call makeList, Swift rewrites the closure body so each line becomes a piece of the result.

Using the builder at the call site

The closure reads like ordinary code, but Swift collects the lines into one array.

let names = makeList {
    "Ava"
    "Noah"
    "Mina"
}

Conceptually, Swift transforms that block into a call that combines three string values.

4. Step-by-Step Examples

Example 1: Building an array of strings

Here is a more complete builder that makes an array of strings from a block.

@resultBuilder
struct MenuBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

func makeMenu(@MenuBuilder content: () -> [String]) -> [String] {
    content()
}

let menu = makeMenu {
    "Home"
    "Profile"
    "Settings"
}

The final value is an array of strings, but the call site looks like a simple list of menu items.

Example 2: Supporting conditionals with buildEither

Builders often need to handle if and else. That is done with buildEither.

@resultBuilder
struct StringBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }

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

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

func makeStrings(@StringBuilder content: () -> [String]) -> [String] {
    content()
}

let isAdmin = true
let messages = makeStrings {
    "Welcome"

    if isAdmin {
        "Admin tools"
    } else {
        "Standard tools"
    }
}

This lets the closure include branching logic while still producing one final combined result.

Example 3: Handling optional content with buildOptional

Optional sections are often useful when some content may or may not exist.

@resultBuilder
struct NotesBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }

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

func makeNotes(@NotesBuilder content: () -> [String]) -> [String] {
    content()
}

let includeReminder = false
let notes = makeNotes {
    "Read chapter 1"

    if includeReminder {
        "Submit homework"
    }
}

When the condition is false, the optional branch contributes nothing to the final result.

Example 4: Iteration with buildArray

Builders can also support loops. Swift converts the loop body into repeated partial results and then combines them with buildArray.

@resultBuilder
struct TagBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }

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

func makeTags(@TagBuilder content: () -> [String]) -> [String] {
    content()
}

let topics = ["Swift", "Builders", "DSLs"]
let tags = makeTags {
    for topic in topics {
        topic
    }
}

This example shows why builders are useful for repeated content: the loop remains readable, while the builder decides how to combine the pieces.

5. Practical Use Cases

Result builders are a strong fit when you want a declarative syntax for structured output.

They are less useful when the logic is highly imperative, depends on many side effects, or would be clearer as a normal function with loops and intermediate variables.

6. Common Mistakes

Mistake 1: Forgetting to provide the builder methods Swift needs

A builder type must support the kinds of statements you use inside the closure. If you only implement buildBlock, then if, else, and loops will not work.

Problem: Swift cannot transform the branch or loop because the builder does not define the required entry points.

@resultBuilder
struct SimpleBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

func makeItems(@SimpleBuilder content: () -> [String]) -> [String] {
    content()
}

let flag = true
let items = makeItems {
    if flag {
        "Enabled"
    }
}

Fix: Add the matching builder methods, such as buildEither for branching.

@resultBuilder
struct SimpleBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }

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

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

func makeItems(@SimpleBuilder content: () -> [String]) -> [String] {
    content()
}

let fixedItems = makeItems {
    if flag {
        "Enabled"
    }
}

The corrected version works because the builder now understands conditional branches.

Mistake 2: Returning the wrong component type

All expressions inside the builder body must eventually fit the component type expected by the builder methods. Mixing incompatible types causes type-checking failures.

Problem: The builder expects String components, but the block contains an Int.

@resultBuilder
struct StringOnlyBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

func makeStrings(@StringOnlyBuilder content: () -> [String]) -> [String] {
    content()
}

let values = makeStrings {
    "One"
    2
}

Fix: Convert all entries to the type the builder expects, or redesign the builder to accept a more general component type.

let fixedValues = makeStrings {
    "One"
    "2"
}

The corrected version works because every expression now matches the builder’s component type.

Mistake 3: Expecting the closure to behave like normal imperative code

Result builders are not a general replacement for every closure. You usually cannot rely on arbitrary local statements to produce a value unless the builder explicitly supports them.

Problem: The closure looks like regular code, but the builder only knows how to combine supported expressions.

@resultBuilder
struct MessageBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

func makeMessages(@MessageBuilder content: () -> [String]) -> [String] {
    content()
}

let messages = makeMessages {
    let greeting = "Hello"
    greeting
}

Fix: Keep intermediate logic outside the builder closure, then pass only supported expressions into the builder body.

let greeting = "Hello"
let fixedMessages = makeMessages {
    greeting
}

The fixed version works because the builder receives a supported expression instead of relying on hidden control flow.

7. Best Practices

Prefer builders for structure, not side effects

Builders are strongest when the closure describes data to be combined, not when it performs work step by step. That keeps the API predictable and easier to reason about.

// Better: describe content
let items = makeMenu {
    "Home"
    "Help"
}

Use a normal function if you mostly need imperative processing, logging, or mutation.

Keep builder component types small and clear

The more complicated the component type, the harder the builder is to debug. Simple component types make builder failures easier to understand.

// Better: clear component type
@resultBuilder
struct RouteBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

Clear types reduce confusion when the compiler reports a type mismatch inside the builder closure.

Add only the builder entry points you need

It is tempting to implement every possible helper method, but extra complexity can make the builder harder to maintain. Add support for conditionals or loops only when the call site really needs them.

// Better: start minimal and extend only when required
@resultBuilder
struct RuleBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

A smaller builder is easier to test and less likely to accept patterns you did not intend.

8. Limitations and Edge Cases

Note: When a builder closure stops compiling, the failure often comes from a missing builder method or a component-type mismatch rather than from the visible syntax alone.

9. Practical Mini Project

Let’s build a small validation rule system. The goal is to define a list of rules in a readable block and then run them against an input string.

struct ValidationRule {
    let message: String
    let isValid: (String) -> Bool
}

@resultBuilder
struct ValidationBuilder {
    static func buildBlock(_ components: ValidationRule...) -> [ValidationRule] {
        components
    }

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

func makeValidationRules(@ValidationBuilder content: () -> [ValidationRule]) -> [ValidationRule] {
    content()
}

func validate(input: String, rules: [ValidationRule]) -> [String] {
    rules.compactMap { rule in
        rule.isValid(input) ? nil : rule.message
    }
}

let includeLengthRule = true

let rules = makeValidationRules {
    ValidationRule(message: "Must not be empty") { !$0.isEmpty }

    ValidationRule(message: "Must contain at least one number") { text in
        text.contains { $0.isNumber }
    }

    if includeLengthRule {
        ValidationRule(message: "Must be at least 8 characters") { $0.count >= 8 }
    }
}

let errors = validate(input: "pass12", rules: rules)

This mini project shows a practical pattern: define a small value type, use a builder to collect rules, then evaluate those rules elsewhere. The builder keeps the rule definition readable while the validation logic stays separate.

10. Key Points

11. Practice Exercise

Create a result builder that collects String values into an array and use it to define a simple list of course topics.

Expected output: An array containing the topic names in the order they were written.

Hint: Start with buildBlock only. You do not need conditionals or loops for this exercise.

Solution:

@resultBuilder
struct TopicBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

func makeTopics(@TopicBuilder content: () -> [String]) -> [String] {
    content()
}

let topics = makeTopics {
    "Syntax"
    "Types"
    "Builders"
}

12. Final Summary

Swift result builders let you express structured output in a readable, declarative block. They are a powerful language feature for building custom DSLs and for making APIs feel natural at the call site.

To use them well, focus on the shape of the value you are building and define only the builder methods you need. When the component types match the content you want to write, result builders can make Swift code much clearer without giving up type safety.

If you want to go further, study how SwiftUI uses result builders and then experiment with your own small DSL for validation, menus, or configuration.