Why Result Builders Matter

When I first saw SwiftUI’s view‑building syntax, the magic wasn’t the layout engine — it was the way a handful of lines could express an entire UI tree. That power comes from result builders, a language feature that lets you turn a block of statements into a single aggregated value. In everyday work, I reach for them whenever I need a tiny DSL: constructing request pipelines, assembling test data, or even generating SQL fragments. The payoff is code that reads like a spec rather than a series of imperative calls.

A Real‑World Example: Building a DSL for Network Requests

Imagine a networking layer where each endpoint is described by a chain of modifiers — headers, query items, body encoders, retry policies. Without a builder you end up with a fluent API that still forces you to store intermediate objects or repeat boilerplate. With a result builder the call site becomes:

let request = APIRequest {
    BaseURL("https://api.example.com")
    Path("/users")
    Method(.get)
    Header("Accept", "application/json")
    QueryItem(name: "page", value: "1")
    QueryItem(name: "limit", value: "20")
    RetryPolicy(maxAttempts: 3, backoff: .exponential)
}

All the components are collected, validated, and turned into a single URLRequest under the hood. The caller never sees a mutable builder instance.

Implementation Walkthrough

First, define the protocol that each component conforms to. Keeping it tiny makes the builder flexible.

protocol RequestComponent {
    func apply(to request: inout URLRequest) throws
}

Next, concrete components. Notice the @dynamicMemberLookup trick on Header — it lets us write Header("Content-Type", "application/json") without a custom initializer for every header name.

struct BaseURL: RequestComponent {
    let url: String
    func apply(to request: inout URLRequest) throws {
        guard let u = URL(string: url) else { throw URLError(.badURL) }
        request.url = u
    }
}

struct Path: RequestComponent {
    let path: String
    func apply(to request: inout URLRequest) throws {
        request.url?.appendPathComponent(path)
    }
}

struct Method: RequestComponent {
    let method: HTTPMethod
    func apply(to request: inout URLRequest) throws { request.httpMethod = method.rawValue }
}

struct Header: RequestComponent {
    let name: String
    let value: String
    func apply(to request: inout URLRequest) throws { request.setValue(value, forHTTPHeaderField: name) }
}

struct QueryItem: RequestComponent {
    let name: String
    let value: String
    func apply(to request: inout URLRequest) throws {
        var comps = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)!
        comps.queryItems = (comps.queryItems ?? []) + [URLQueryItem(name: name, value: value)]
        request.url = comps.url
    }
}

struct RetryPolicy: RequestComponent {
    let maxAttempts: Int
    let backoff: BackoffStrategy
    // stored for later use by the networking stack
    func apply(to request: inout URLRequest) throws { /* no‑op */ }
}

Now the result builder itself. The buildBlock overloads collect an arbitrary number of components into an array. buildExpression simply forwards a single component.

@resultBuilder
enum RequestBuilder {
    static func buildExpression(_ expression: RequestComponent) -> [RequestComponent] {
        [expression]
    }

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

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

    static func buildEither(first: [RequestComponent]) -> [RequestComponent] { first }
    static func buildEither(second: [RequestComponent]) -> [RequestComponent] { second }
    static func buildArray(_ components: [[RequestComponent]]) -> [RequestComponent] {
        components.flatMap { $0 }
    }
}

Finally, the entry point that turns the component list into a ready‑to‑send URLRequest.

struct APIRequest {
    private let components: [RequestComponent]
    
    init(@RequestBuilder _ builder: () -> [RequestComponent]) {
        self.components = builder()
    }
    
    func makeURLRequest() throws -> URLRequest {
        var request = URLRequest(url: URL(string: "about:blank")!)
        for component in components {
            try component.apply(to: &request)
        }
        return request
    }
}

Gotchas and Best Practices

  • Type safety first. Keep the component protocol minimal; avoid Any or type‑erased wrappers unless you truly need heterogeneity.
  • Fail fast. Validate URLs, header names, and query encoding inside apply(to:) so the caller gets a clear error at construction time, not at send time.
  • Don’t over‑engineer. Result builders shine for shallow, declarative trees. Deeply nested conditional logic can make the generated buildEither / buildArray code hard to debug.
  • Document the DSL. A quick README or inline doc comments on each component save future maintainers from guessing the order‑sensitivity of modifiers.

Result builders are compile‑time syntactic sugar — they generate ordinary Swift code. That means the debugger steps through the same functions you wrote, not through hidden closure magic.

Wrapping Up

Adopting a result‑builder‑based DSL for network requests trimmed our endpoint definitions from 30‑line builder chains to a handful of readable lines. The same pattern now powers our test‑data factories and a lightweight SQL fragment builder. If you find yourself writing repetitive builder.setX(...).setY(...) code, ask whether a tiny declarative language would express the intent more clearly. In Swift, the tooling is already there — you just have to reach for it.