Skip to Content
Course content

128: Practice Exercise: Building a Custom Result Builder (DSL)

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen a lot of developers try to build "configuration" systems by just passing around arrays of objects. It works, but it always feels like you're fighting the language. If you've used SwiftUI, you know how satisfying it is to just list your views inside a closure without commas, brackets, or explicit return statements. That's the magic of result builders. Today, we're going to build our own to create a Domain Specific Language (DSL) for a notification rule engine.

The Friction of Manual Array Construction

Imagine we're building a system where a user can define a set of rules for when a notification should trigger. In a naive implementation, you'd probably define a Rule struct and a RuleSet that holds an array of them. To build a set of rules, you'd do something like this:

struct Rule {
    let description: String
    let priority: Int
}

struct RuleSet {
    let rules: [Rule]
}

// The "naive" way
let myRules = RuleSet(rules: [
    Rule(description: "User is premium", priority: 1),
    Rule(description: "Device is on battery", priority: 2),
    Rule(description: "Time is after 9 PM", priority: 3)
])

Now, this isn't "wrong"β€”it's perfectly valid Swift. But as your rules get more complex, the syntax gets noisy. You're constantly staring at commas and square brackets. If you want to conditionally add a rule based on a flag, you have to break out of the array initialization and use append(), which completely destroys the declarative feel of the code. It turns a "definition" into a series of "instructions."

Using @resultBuilder to Clear the Noise

We can get rid of that boilerplate by creating a result builder. A result builder is essentially a piece of glue that tells the Swift compiler: "Whenever you see a closure marked with this attribute, don't treat it as a standard function; instead, collect all the expressions inside and pass them to these specific methods."

To do this, we define a type (usually named with a Builder suffix) and mark it with @resultBuilder. The core requirement is a buildBlock method that takes a variadic list of our target type and returns a combined result.

@resultBuilder
struct RuleBuilder {
    static func buildBlock(_ components: Rule...) -> [Rule] {
        return components
    }
}

struct RuleSet {
    let rules: [Rule]
    
    // We use the builder here in the initializer
    init(@RuleBuilder _ builder: () -> [Rule]) {
        self.rules = builder()
    }
}

// Now look at the difference:
let myRules = RuleSet {
    Rule(description: "User is premium", priority: 1)
    Rule(description: "Device is on battery", priority: 2)
    Rule(description: "Time is after 9 PM", priority: 3)
}

I love this because it shifts the focus from the container (the array) to the content (the rules). It reads like a configuration file rather than a piece of imperative code.

Handling Logic and Optionals

The real power comes when you want your DSL to handle more than just a static list. If you want to support if statements or for loops inside your RuleSet, you can't just use buildBlock. You have to implement buildOptional and buildEither.

When the compiler sees an if statement without an else, it calls buildOptional. If it sees an if/else, it calls buildEither. Without these, your builder is essentially a static list. By adding them, you allow the person using your DSL to inject actual logic into the declaration process without leaving the clean, declarative block.

The trade-off here is a bit of upfront complexity. You're writing more code in the builder to save the user of the builder from writing tedious code. In my experience, if the DSL is going to be used in more than three places in your codebase, the investment in the result builder pays for itself almost immediately in terms of readability and reduced merge conflicts.




πŸ“‹ Practical Task

Exercise: Building a Mock API Response DSL

You are tasked with creating a DSL that allows developers to quickly mock API responses for testing. Instead of manually creating arrays of "Response Fields," you will build a ResponseBuilder.

Requirements:

  • Create a Field struct with two properties: key: String and value: Any.
  • Create a @resultBuilder` struct named ResponseBuilder that can collect multiple Field objects into an array [Field].
  • Create a MockResponse struct that takes a ResponseBuilder closure in its initializer.
  • Implement buildOptional within your ResponseBuilder so that a field can be conditionally added using an if statement.

Test your implementation with the following scenario:

let isAdmin = true
let response = MockResponse {
    Field(key: "status", value: 200)
    Field(key: "version", value: "1.0")
    if isAdmin {
        Field(key: "debug_info", value: "Admin Access Granted")
    }
}

Ensure that the final MockResponse contains three fields if isAdmin is true, and two fields if it is false.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.