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
- How Result Builders Work
- Building an HTML DSL
- Supporting Conditionals and Loops
- A Second Example: Animation Sequence Builder
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
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:
buildArrayreceives[String]— the type returned by the closure body for each iteration. Make sure the component type you pass tobuildArraymatches 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
returnstatements,break,continue, orthrowinside a result builder closure unless the builder explicitly handles them viabuildLimitedAvailabilityor dedicatedbuildBlockoverloads. Attempting to usereturnin 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)
| Scenario | Recommendation |
|---|---|
| Building declarative UI configuration (like SwiftUI) | Strong fit — result builders were designed for this |
| HTML/XML/DSL generation with mixed types | Good 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 values | Prefer arrays or variadic parameters — simpler |
| Branching logic that is genuinely complex | Avoid — deeply nested buildEither chains are hard to debug |
| APIs consumed by beginners or auto-generated code | Avoid — the magic is helpful for humans, confusing for tooling |
| Performance-critical inner loops | Avoid — prefer explicit data structures |
Summary
- A result builder is a type annotated with
@resultBuilderthat defines staticbuild*methods. The compiler rewrites annotated closure bodies into calls to those methods. buildBlockis the only required method.buildOptional,buildEither,buildArray, andbuildExpressionunlock progressively more expressive closure syntax.buildExpressionis the right tool when your DSL accepts multiple distinct input types — define one overload per type to unify them into a common component.buildFinalResultlets 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.