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.
- They are used to construct one final value from several smaller pieces.
- They make APIs read declaratively rather than imperatively.
- They are commonly used to build trees, lists, rules, layouts, and configurations.
- They are a compile-time transformation, not a runtime parser.
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:
- Write declarative code that reflects the final structure you want.
- Hide repetitive construction logic inside reusable builder methods.
- Create custom DSLs for configuration, layout, validation, and composition.
- Keep call sites clean when many optional branches or repeated items are involved.
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.
- Building UI trees, especially view hierarchies.
- Defining validation rules for forms or input pipelines.
- Assembling menu structures, navigation items, or command lists.
- Creating configuration blocks for routers, styles, or feature flags.
- Generating markup-like output such as attributed text fragments or document sections.
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
- Result builders are not magic parsers; they still work within Swift’s type system and syntax rules.
- Not every statement is supported automatically. The builder must provide methods for the statement forms you want to use.
- Complex builder bodies can produce long compiler error messages because type inference has more work to do.
- Builders are best for building values, not for performing effects such as file I/O or networking.
- Some APIs require specific overloads or annotations to preserve inference, especially when component types are generic.
- If a closure becomes too large, the code may be harder to read than an ordinary loop or helper function.
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
- Result builders turn a closure body into a single value.
- They are declared with @resultBuilder.
- buildBlock combines ordinary expressions.
- buildEither supports if and else.
- buildOptional supports optional branches.
- buildArray supports loops.
- They are ideal for declarative APIs and custom DSLs.
- They are limited by the builder methods and component types you define.
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.
- Define a builder type named TopicBuilder.
- Make it combine multiple String values into [String].
- Write a function that accepts a builder closure and returns the built array.
- Call it with at least three topic names.
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.