Building Declarative APIs in Swift with Result Builders
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
Anyor 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/buildArraycode 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.