Introduction
Result builders are one of Swift's most powerful yet underutilized features, and they're the backbone of why SwiftUI feels so natural to write. Understanding result builders: Swift & iOS best practices explained transforms how you'll approach domain-specific languages (DSLs) and view hierarchies in your apps. In this guide, we'll walk through what result builders actually do, how ViewBuilder powers SwiftUI, and how to build your own for maximum flexibility.
What @resultBuilder Actually Does
A result builder is a type that transforms a series of statements into a single value. Rather than forcing developers to manually construct nested data structures, result builders let us write clean, sequential code that gets automatically converted into the desired structure. Think of it as a compiler-level transformation that intercepts your code block and rebuilds it into something else.
When you mark a function parameter or closure with @resultBuilder, you're telling Swift: "Whatever code the developer writes in this block, pass it to my result builder type so I can transform it." The result builder then implements static methods like buildBlock, buildIf, and buildArray to handle different syntactic patterns.
Without result builders, creating a complex view hierarchy in SwiftUI would look like this:
VStack(
alignment: .leading,
spacing: 16,
content: {
Text("Hello")
Spacer()
Button("Tap me") { }
}
)With @ViewBuilder (a result builder), we write the natural, clean version:
VStack(alignment: .leading, spacing: 16) {
Text("Hello")
Spacer()
Button("Tap me") { }
}The difference isn't just syntax sugar—it's about readability, maintainability, and developer experience. Result builders eliminate the cognitive overhead of mentally nesting closures.
ViewBuilder: Building SwiftUI View Hierarchies
SwiftUI's @ViewBuilder is the canonical example of a result builder in production. Every SwiftUI container—VStack, HStack, ZStack, Group, ForEach—uses it. Understanding ViewBuilder helps us grasp why certain SwiftUI patterns work the way they do.
ViewBuilder implements methods like:
buildBlock: handles sequential viewsbuildIf: handles optional viewsbuildEither: handles conditional branchesbuildArray: handles loops and dynamic content
Here's a practical scenario: suppose we're building a user profile card that conditionally shows different sections based on user state.
struct ProfileCard<Content: View>: View {
let user: User
@ViewBuilder var content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "person.circle")
VStack(alignment: .leading) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.caption)
.foregroundColor(.secondary)
}
}
Divider()
content()
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
// Usage
ProfileCard(user: currentUser) {
if user.isPremium {
Label("Premium Member", systemImage: "star.fill")
}
if let location = user.location {
Label(location, systemImage: "mappin")
}
Button(action: { /* edit */ }) {
Text("Edit Profile")
}
}Notice how we can write multiple views sequentially inside the closure, and even use if statements directly. Without ViewBuilder, we'd need to manually wrap these in tuples or arrays. ViewBuilder's buildIf method handles the conditional logic, and buildBlock chains the views together.
The magic happens at compile time. When Swift sees your sequential statements inside a @ViewBuilder block, it transforms them into a single view type. This is why SwiftUI views can have up to 10 direct children in a container—ViewBuilder chains them into a nested generic type that satisfies the View protocol.
Writing Your Own Result Builders for DSLs
Result builders aren't limited to SwiftUI. They're perfect for building domain-specific languages within Swift. Let's build a practical example: a fluent API for constructing HTML or structured data.
First, define your result builder type:
@resultBuilder
struct FormBuilder {
static func buildBlock(_ components: FormField...) -> [FormField] {
components
}
static func buildIf(_ component: [FormField]?) -> [FormField] {
component ?? []
}
static func buildEither(first: [FormField]) -> [FormField] {
first
}
static func buildEither(second: [FormField]) -> [FormField] {
second
}
}
struct FormField {
let label: String
let type: FieldType
let isRequired: Bool
}
enum FieldType {
case text
case email
case password
case checkbox
}
struct Form {
let title: String
@FormBuilder var fields: [FormField]
init(
title: String,
@FormBuilder _ fieldBuilder: () -> [FormField]
) {
self.title = title
self.fields = fieldBuilder()
}
}Now we can build forms with a clean, declarative syntax:
let registrationForm = Form(title: "Sign Up") {
FormField(label: "Email", type: .email, isRequired: true)
FormField(label: "Password", type: .password, isRequired: true)
FormField(label: "Confirm Password", type: .password, isRequired: true)
if includeNewsletterSignup {
FormField(label: "Subscribe to newsletter", type: .checkbox, isRequired: false)
}
}This is far cleaner than manually constructing an array. Your team members can add fields intuitively without thinking about array syntax or closures. The result builder handles the transformation from sequential statements into a structured array.
For a more complex example, consider building a state machine DSL:
@resultBuilder
struct StateBuilder {
static func buildBlock(_ components: State...) -> [State] {
components
}
}
struct State {
let name: String
let onEnter: (() -> Void)?
let transitions: [Transition]
}
struct Transition {
let event: String
let targetState: String
}
struct StateMachine {
@StateBuilder var states: [State]
}Result builders eliminate boilerplate and make your DSL feel native to Swift. Developers using your API can read it like prose rather than code.
Supporting if and for in Blocks
One of result builders' most powerful features is transparent support for control flow. You don't need to do anything special—implement the right methods and Swift handles it automatically.
For if statements, implement buildIf and buildEither:
@resultBuilder
struct ArrayBuilder<T> {
static func buildBlock(_ components: T...) -> [T] {
components
}
// Handles if without else (optional)
static func buildIf(_ component: [T]?) -> [T] {
component ?? []
}
// Handles if-else branching
static func buildEither(first: [T]) -> [T] {
first
}
static func buildEither(second: [T]) -> [T] {
second
}
// Handles for-in loops
static func buildArray(_ components: [[T]]) -> [T] {
components.flatMap { $0 }
}
}With these methods, developers can write intuitive control flow:
struct DataList {
let items: [String]
@ArrayBuilder<String>
var processedItems: [String] {
"Header"
if items.count > 0 {
"Has items:"
for item in items {
item.uppercased()
}
} else {
"Empty list"
}
"Footer"
}
}When this code compiles, Swift transforms it into a single array through a series of buildBlock, buildIf, buildEither, and buildArray calls. The developer writes imperative-looking code, but the result builder constructs the declarative data structure.
This is particularly powerful in SwiftUI when dealing with dynamic lists:
struct ContentView: View {
let numbers = [1, 2, 3, 4, 5]
var body: some View {
VStack {
Text("Numbers:")
if !numbers.isEmpty {
ForEach(numbers, id: \.self) { number in
Text("\(number)")
}
} else {
Text("No numbers")
}
}
}
}ViewBuilder's buildArray method handles the ForEach internally, allowing loops to work seamlessly alongside regular views.
Common Mistakes
Mistake 1: Forgetting to implement buildIf for optional behavior. If your result builder needs to support conditional views but you only implement buildBlock, the compiler will error. Always include both buildIf and buildEither if you expect developers to use conditionals.
Mistake 2: Returning the wrong type from buildBlock. A common error is returning a single component instead of an array when you need to combine multiple elements. If your builder chains five statements, buildBlock receives all five as separate arguments and must combine them appropriately.
Mistake 3: Misunderstanding buildArray scope. The buildArray method receives an array of already-transformed components. If you're building a DSL where loops should produce multiple final items, ensure your loop transformation happens before buildArray receives it, or implement buildArray to handle the nested structure correctly.
Mistake 4: Overcomplicating the type system. Result builders work best when the result type is simple and consistent. If you're trying to build a heterogeneous collection (mixing types), you'll fight the type checker. Keep your builder's output type focused.
Key Takeaways
- Result builders transform sequential statements into structured values at compile time, eliminating nested closure boilerplate.
- ViewBuilder powers SwiftUI's clean syntax by implementing
buildBlock,buildIf,buildEither, andbuildArraymethods. - Build your own result builders for domain-specific languages—form builders, configuration systems, or state machines—to create intuitive APIs that feel native to Swift.
- Implement
buildIfand bothbuildEitheroverloads if you want developers to useifstatements; implementbuildArrayto support loops. - Result builders handle control flow transparently; you don't need special syntax, just the right static methods.
- Test result builder edge cases thoroughly: empty blocks, deeply nested conditionals, and mixed control flow patterns often reveal design issues early.






Comments
Loading comments…