ResultBuilders - How Swift's @resultBuilder Works From ViewBuilder Internals to Writing Your Own Production DSL
ResultBuilders - How Swift's @resultBuilder Works From ViewBuilder Internals to Writing Your Own Production DSL
ResultBuilders - How Swift's @resultBuilder Works From ViewBuilder Internals to Writing Your Own Production DSL
Every SwiftUI view you have ever written is secretly a function call you never made.
VStack {
Text("Hello")
Image(systemName: "star")
if isLoggedIn {
Button("Profile") { }
}
}
That trailing closure is not an array literal. It has no commas. It contains an if statement with no enclosing collection. It returns multiple values of different types from a single expression scope. None of this is standard Swift. All of it is legal Swift — because the compiler is silently rewriting every line of it before it ever reaches the type checker.
The mechanism responsible is @resultBuilder. It is one of the most sophisticated compile-time features in the Swift language, used not just by SwiftUI but by Swift Testing, Swift Charts, Swift ArgumentParser, and any library author who wants to give their API a declarative surface. This article reverse-engineers the entire system: what the compiler does to your closure, what methods it looks for, how if/else, for loops, and availability checks are each handled differently, and how to build a complete production-grade DSL of your own.
By the end, you will understand SwiftUI's view builder well enough to explain it in an interview, extend it when needed, and design your own domain-specific language with the same compile-time power.
The Problem: Swift Closures Are Expression-Hostile
Swift closures follow a strict rule: every non-Void expression must contribute to a return value, and there can only be one. Writing a closure that "returns" four independent values of different types is a type error.
// This is illegal standard Swift — what type does this closure return?
let build: () -> ??? = {
Text("Hello")
Image(systemName: "star")
Divider()
}
SwiftUI needed exactly this capability for its entire API surface to work. The WWDC 2019 team's answer was a feature called "function builders", which became the formally specified @resultBuilder in Swift 5.4 (SE-0289). The idea: annotate a closure parameter so the compiler intercepts that closure's body and transforms each statement into a series of named static method calls on a type you define.
Step 1: What @resultBuilder Is
@resultBuilder is a Swift attribute that you apply to a struct, class, enum, or actor to declare it as a result builder type. Once annotated, that type can be used as an attribute on:
- A function parameter of closure type
- A function or computed property directly
When the Swift compiler encounters a closure annotated with a result builder, it does not execute the closure normally. Instead it syntactically transforms the closure body into a sequence of static method calls on your result builder type.
The minimum viable result builder is one method:
@resultBuilder
struct StringJoiner {
static func buildBlock(_ parts: String...) -> String {
parts.joined(separator: " ")
}
}
Apply it to a function:
func sentence(@StringJoiner _ content: () -> String) -> String {
content()
}
Now this compiles:
let s = sentence {
"The quick"
"brown fox"
"jumps"
}
// s == "The quick brown fox jumps"
The compiler has transformed the closure body into:
StringJoiner.buildBlock("The quick", "brown fox", "jumps")
No commas. No explicit array. The syntactic transformation happens entirely at compile time — no runtime overhead.
Step 2: The Full Method Protocol — Every Transformation Explained
A result builder is not one method. It is a menu of up to nine static methods, each handling a specific Swift control-flow construct. The compiler looks for each by exact name. If a method is absent, the corresponding construct is illegal inside closures annotated with your builder.
Here is the complete map:
buildBlock(_ components: C...) -> C
The core method. Called with every top-level expression in the closure body. The return type of buildBlock is the type of the closure.
static func buildBlock(_ components: Component...) -> Component
For ViewBuilder, components is some View, and the return is also some View. The compiler fuses up to 10 views into a TupleView using overloaded buildBlock variants.
buildExpression(_ expression: E) -> C
An optional adapter. When present, it is called before buildBlock — once per expression, converting the expression's type E into the component type C that buildBlock expects.
// Without buildExpression:
// Closure statements must already be of type C
// With buildExpression:
// Closure statements can be of type E, which is adapted to C
static func buildExpression(_ expression: E) -> C
This is how a result builder can accept multiple input types. You can overload buildExpression for different input types — the compiler picks the matching overload:
@resultBuilder
struct ConstraintBuilder {
static func buildExpression(_ c: NSLayoutConstraint) -> [NSLayoutConstraint] { [c] }
static func buildExpression(_ cs: [NSLayoutConstraint]) -> [NSLayoutConstraint] { cs }
static func buildBlock(_ cs: [NSLayoutConstraint]...) -> [NSLayoutConstraint] {
cs.flatMap { $0 }
}
}
buildOptional(_ component: C?) -> C
Handles if-without-else statements. When the condition is false at runtime, nil is passed; when true, the component is passed. Your implementation decides what "absent" means:
static func buildOptional(_ component: [View]?) -> [View] {
component ?? []
}
Critical: buildOptional is required for if-without-else. Without it, the compiler rejects any unmatched if in the closure.
buildEither(first:) and buildEither(second:)
Handle if/else and switch statements. The compiler wraps the if branch in buildEither(first:) and the else branch in buildEither(second:). Both overloads must return the same type:
static func buildEither(first component: C) -> C { component }
static func buildEither(second component: C) -> C { component }
In SwiftUI's ViewBuilder, these return _ConditionalContent<TrueContent, FalseContent>, a concrete type that carries both branches and selects at runtime. This is why the return type of an if/else in SwiftUI is some View — the concrete type is _ConditionalContent, erased behind the protocol.
buildArray(_ components: [C]) -> C
Handles for loops. The compiler iterates the loop, collects each component from buildExpression into an array, then calls buildArray once with the whole array:
static func buildArray(_ components: [[Element]]) -> [Element] {
components.flatMap { $0 }
}
Without buildArray, for loops are illegal inside your builder closures.
buildLimitedAvailability(_ component: C) -> C
Handles #available checks. When a block is guarded by if #available(iOS 17, *), the compiler wraps the content of that block in buildLimitedAvailability. This allows you to erase type information that only exists on newer OS versions, so the outer scope can still type-check:
static func buildLimitedAvailability(_ component: some View) -> AnyView {
AnyView(component)
}
SwiftUI uses this to erase new view types (like ContentUnavailableView on iOS 17) into AnyView so that code targeting older deployment targets still compiles.
buildFinalResult(_ component: C) -> R
An optional post-processing step. Called last, after buildBlock, on the final assembled component. Lets you transform the builder's internal type C into a different external return type R:
// Builder accumulates [NSLayoutConstraint] internally,
// but returns Void and activates them
static func buildFinalResult(_ constraints: [NSLayoutConstraint]) {
NSLayoutConstraint.activate(constraints)
}
Step 3: The Complete Compiler Transform — Traced Step by Step
Let's take a concrete closure and trace every transformation the compiler performs. Given:
@ViewBuilder
var body: some View {
Text("Title")
if showSubtitle {
Text("Subtitle")
}
ForEach(items) { item in
Text(item.name)
}
}
The compiler produces something equivalent to:
var body: some View {
// Step 1: Each top-level expression becomes buildExpression
let v0 = ViewBuilder.buildExpression(Text("Title"))
// Step 2: if-without-else becomes buildOptional
let v1: Text?
if showSubtitle {
let inner = ViewBuilder.buildExpression(Text("Subtitle"))
v1 = ViewBuilder.buildBlock(inner) // single-element block
} else {
v1 = nil
}
let v1_result = ViewBuilder.buildOptional(v1)
// Step 3: ForEach is already a View — buildExpression passes it through
let v2 = ViewBuilder.buildExpression(ForEach(items) { item in
ViewBuilder.buildExpression(Text(item.name))
})
// Step 4: All components go into buildBlock
return ViewBuilder.buildBlock(v0, v1_result, v2)
}
The most important takeaway: each control flow construct becomes a specific static method call. The result builder is not a runtime interpreter. It is a template the compiler uses to write code on your behalf. The generated code is completely type-safe and carries zero overhead compared to writing the equivalent explicit code yourself.
Step 4: ViewBuilder — SwiftUI's Real Implementation
SwiftUI's ViewBuilder is the result builder you interact with constantly without seeing it. Here is a simplified but accurate representation of its implementation:
@resultBuilder
public struct ViewBuilder {
// Empty closure
public static func buildBlock() -> EmptyView { EmptyView() }
// Single view — no wrapping needed
public static func buildBlock<C: View>(_ content: C) -> C { content }
// Two views → TupleView
public static func buildBlock<C0: View, C1: View>(
_ c0: C0, _ c1: C1
) -> TupleView<(C0, C1)> { TupleView((c0, c1)) }
// Three views → TupleView<(C0, C1, C2)>
// ... continues up to 10 overloads ...
// if-without-else
public static func buildOptional<C: View>(_ content: C?) -> C? { content }
// if/else — produces _ConditionalContent
public static func buildEither<TrueContent: View, FalseContent: View>(
first view: TrueContent
) -> _ConditionalContent<TrueContent, FalseContent> {
.init(storage: .trueContent(view))
}
public static func buildEither<TrueContent: View, FalseContent: View>(
second view: FalseContent
) -> _ConditionalContent<TrueContent, FalseContent> {
.init(storage: .falseContent(view))
}
// #available checks
public static func buildLimitedAvailability<C: View>(_ content: C) -> AnyView {
AnyView(content)
}
}
Why 10 overloads of buildBlock? Because TupleView is a concrete generic type, and each distinct arity requires a distinct generic signature. This is why you get a compiler error when you put more than 10 direct children in a single VStack — there is no buildBlock overload that accepts 11 arguments. The fix is to wrap children in a Group, which resets the arity count for the inner ViewBuilder scope.
Why does if/else produce _ConditionalContent instead of AnyView? Because SwiftUI's diffing engine uses the concrete type to determine identity across re-renders. _ConditionalContent tells SwiftUI: "there are two possible subtrees, and only one is active." SwiftUI can animate between them correctly. AnyView erases this information and breaks animation identity.
Step 5: The buildLimitedAvailability Trap
This is one of the most misunderstood result builder methods and one of the most relevant for production iOS code that needs to support a range of OS versions.
var body: some View {
if #available(iOS 17, *) {
ContentUnavailableView("No Results", systemImage: "magnifyingglass")
} else {
Text("No Results")
}
}
ContentUnavailableView only exists on iOS 17+. Without buildLimitedAvailability, the compiler has a problem: the if #available true branch produces a type that does not exist in the compilation target's type system on older OS versions.
When ViewBuilder.buildLimitedAvailability is called, it wraps the iOS 17 view in AnyView, producing a type-erased AnyView that can be type-checked against all deployment targets. The else branch produces a Text. These go into buildEither, which produces _ConditionalContent<AnyView, Text>.
The consequence: any time you use #available in a ViewBuilder scope, the true branch gets type-erased to AnyView. This has real implications: animations that depend on view identity may not work correctly across the availability boundary. For performance-sensitive code, consider structuring the availability check at the view model level instead of inside the builder closure.
Step 6: Building a Production DSL — Route Builder
Let's build something real. The following example constructs a type-safe routing system where you declare routes declaratively in the style of SwiftUI — something genuinely useful in production UIKit apps using coordinator patterns.
Define the Domain Types
struct Route {
let path: String
let handler: (URLComponents) -> UIViewController
}
struct RouteGroup {
let prefix: String
let routes: [Route]
var flattened: [Route] {
routes.map { route in
Route(path: prefix + route.path, handler: route.handler)
}
}
}
Define the Result Builder
@resultBuilder
struct RouteBuilder {
// Core: combine multiple routes/groups into a flat array
static func buildBlock(_ components: [Route]...) -> [Route] {
components.flatMap { $0 }
}
// Adapt a single Route to [Route]
static func buildExpression(_ route: Route) -> [Route] {
[route]
}
// Adapt a RouteGroup (flattens prefix)
static func buildExpression(_ group: RouteGroup) -> [Route] {
group.flattened
}
// Support if-without-else (disabled routes)
static func buildOptional(_ component: [Route]?) -> [Route] {
component ?? []
}
// Support if/else (feature flags, A/B testing)
static func buildEither(first component: [Route]) -> [Route] { component }
static func buildEither(second component: [Route]) -> [Route] { component }
// Support for loops (dynamic route arrays)
static func buildArray(_ components: [[Route]]) -> [Route] {
components.flatMap { $0 }
}
// Final result: activate all routes into a router
static func buildFinalResult(_ routes: [Route]) -> Router {
Router(routes: routes)
}
}
Build the API Surface
func route(_ path: String, to handler: @escaping (URLComponents) -> UIViewController) -> Route {
Route(path: path, handler: handler)
}
func group(prefix: String, @RouteBuilder routes: () -> [Route]) -> RouteGroup {
RouteGroup(prefix: prefix, routes: routes())
}
func buildRouter(@RouteBuilder _ routes: () -> Router) -> Router {
routes()
}
Use It — Feels Like SwiftUI
let router = buildRouter {
route("/home") { _ in HomeViewController() }
route("/search") { _ in SearchViewController() }
group(prefix: "/user") {
route("/profile") { _ in ProfileViewController() }
route("/settings") { _ in SettingsViewController() }
}
if featureFlags.chatEnabled {
route("/chat") { _ in ChatViewController() }
}
for deepLink in registeredDeepLinks {
route(deepLink.path) { _ in deepLink.makeViewController() }
}
}
The call site reads like a declaration. The compiler transforms it into:
let routes: [Route] = RouteBuilder.buildBlock(
RouteBuilder.buildExpression(route("/home") { ... }),
RouteBuilder.buildExpression(route("/search") { ... }),
RouteBuilder.buildExpression(group(prefix: "/user") { ... }),
RouteBuilder.buildOptional(featureFlags.chatEnabled
? RouteBuilder.buildBlock(RouteBuilder.buildExpression(route("/chat") { ... }))
: nil),
RouteBuilder.buildArray(registeredDeepLinks.map { deepLink in
RouteBuilder.buildExpression(route(deepLink.path) { ... })
})
)
let router = RouteBuilder.buildFinalResult(routes)
Every transformation is type-safe, compile-time validated, and zero-overhead at runtime.
Step 7: Composing Multiple Result Builders
Result builders can be nested. A function inside a result builder closure can accept its own @SomeBuilder parameter. This is how SwiftUI composes layouts — VStack uses @ViewBuilder, and inside that closure you can use HStack, which also uses @ViewBuilder.
In your own DSL, you can create hierarchical builders:
@resultBuilder
struct SectionBuilder {
static func buildBlock(_ items: MenuItem...) -> [MenuItem] { Array(items) }
}
@resultBuilder
struct MenuBuilder {
static func buildBlock(_ sections: MenuSection...) -> Menu { Menu(sections: Array(sections)) }
static func buildExpression(_ section: MenuSection) -> MenuSection { section }
}
func section(title: String, @SectionBuilder items: () -> [MenuItem]) -> MenuSection {
MenuSection(title: title, items: items())
}
func buildMenu(@MenuBuilder _ content: () -> Menu) -> Menu {
content()
}
// Usage:
let menu = buildMenu {
section(title: "File") {
MenuItem("New", shortcut: "⌘N")
MenuItem("Open", shortcut: "⌘O")
}
section(title: "Edit") {
MenuItem("Undo", shortcut: "⌘Z")
MenuItem("Redo", shortcut: "⌘⇧Z")
}
}
Step 8: The 10-View Limit and Other Compile-Time Constraints
Understanding ViewBuilder's internals explains several behaviours that confuse developers:
The 10-view limit arises because buildBlock has exactly 10 overloads (0 through 10 parameters). Adding an 11th child exceeds the maximum arity. Solution: Group { } creates a new builder scope, resetting the count.
if/else branches must produce compatible types via buildEither. If you write if condition { someView } else { differentView }, both branches go through buildEither. The resulting _ConditionalContent<A, B> is generic over both branches. This is fine. But nesting multiple if/else chains creates deeply nested _ConditionalContent types — which is why complex view hierarchies with many conditions can slow down compile times significantly. Each additional if/else adds a type parameter.
Type inference across buildBlock overloads means the compiler must resolve which arity overload to use. With 10+ children, it must check all 10 overloads before failing — contributing to quadratic compile-time scaling in pathological cases. This was improved significantly in Swift 5.8 and 5.9 with better result builder type inference.
switch statements work the same as if/else — each case becomes buildEither(first:) or buildEither(second:). Multiple cases are nested: case 1 is first, everything else is second, then case 2 within second is first of a nested buildEither, and so on.
Step 9: Performance — What the Compiler Buys You
Result builders produce zero runtime overhead compared to equivalent explicit code. The transformation is purely syntactic — performed by the Swift compiler before any code generation.
What this means in practice:
// These two are identical at the binary level:
// Declarative (result builder):
@StringBuilder var greeting: String {
"Hello"
name
"!"
}
// Explicit:
var greeting: String {
StringBuilder.buildBlock("Hello", name, "!")
}
The only runtime cost is whatever your buildBlock, buildExpression, etc. implementations do. If they allocate, you pay for that. If they are @inlinable and perform simple operations, the compiler can inline and optimise them away entirely.
SwiftUI's ViewBuilder is heavily @inlinable for this reason. TupleView, _ConditionalContent, and the various buildBlock overloads are all transparent to the compiler, allowing it to fold the entire view tree description into an efficient internal representation.
Step 10: Real-World Applications Beyond SwiftUI
Result builders are not a SwiftUI-only feature. They are a general language mechanism used across the Swift ecosystem:
Swift Testing (#expect and test functions): Swift Testing's @Test and #expect macros use result builders to collect test assertions declaratively.
Swift ArgumentParser: Command-line argument declarations use result builders so you can write @Argument, @Option, and @Flag properties in a declarative group.
Swift Charts: ChartBuilder collects Mark instances (BarMark, LineMark, etc.) using exactly the result builder pattern described here.
AutoLayout DSLs: Libraries like SnapKit and custom in-house constraint builders use @resultBuilder to produce [NSLayoutConstraint] arrays from declarative syntax — eliminating the noise of .isActive = true and NSLayoutConstraint.activate().
Server-side Swift (HTML generation): Libraries like Plot use result builders to produce HTML nodes — the classic example from SE-0289 itself.
Your codebase: Any time you have a pattern of "collect a list of things and do something with them", a result builder can replace the array-of-closures or delegate-callback pattern with a clean declarative API.
The Method Lookup Table
Quick reference for which method the compiler calls for each Swift construct:
| Construct | Method Called | Required? |
|---|---|---|
| Top-level expression | buildExpression(_:) then buildBlock |
buildBlock required |
if without else |
buildOptional(_:) |
No — disables bare if |
if/else |
buildEither(first:) + buildEither(second:) |
No — disables if/else |
switch |
buildEither(first:) + buildEither(second:) |
No — disables switch |
for loop |
buildArray(_:) |
No — disables for |
#available check |
buildLimitedAvailability(_:) |
No — disables #available |
| Post-processing | buildFinalResult(_:) |
No — identity transform default |
Conclusion
@resultBuilder is one of Swift's deepest compiler features — a mechanism for turning ordinary closures into embedded domain-specific languages by systematically replacing each statement with a call to a method you define. Every time you write a SwiftUI view body, you are using a compiler transform that maps your declarative syntax into TupleView, _ConditionalContent, and buildBlock calls, all invisible, all type-safe, all zero-overhead at runtime.
Understanding the transform makes you a more capable SwiftUI developer: you understand why the 10-view limit exists, why #available erases to AnyView, why if/else and bare if require different builder methods, and why deeply nested conditional views can slow compile times.
And beyond SwiftUI, you now have a complete mental model for designing your own DSLs — whether for routing, constraint declaration, menu building, HTML generation, or any domain where a declarative API would be more expressive than an imperative one.
The compiler is your co-author. @resultBuilder is how you tell it what language you want it to write.