Understanding @resultBuilder in Swift: Beyond SwiftUI


Greetings, travelere!

Swift’s @resultBuilder attribute is a powerful feature that simplifies the creation of domain-specific languages (DSLs) within Swift, enabling expressive and concise declarative code. While it is most prominently used in SwiftUI via @ViewBuilder, its applications extend to various other domains. This article explores the mechanics of resultBuilder, its role in SwiftUI through @ViewBuilder, and practical examples of its use outside SwiftUI, including robust implementations for SQL query building and validation rule configuration.

What is resultBuilder?

The @resultBuilder attribute (formerly @functionBuilder) allows developers to transform a sequence of expressions into a single value using a declarative syntax. It achieves this through a set of static methods defined in a resultBuilder-conforming type, which specify how expressions are collected, combined, and transformed into a final output. This feature enables Swift to interpret blocks of code as structured results, making it ideal for creating intuitive APIs that resemble DSLs.

resultBuildre in SwiftUI: The Role of ViewBuilder

In SwiftUI, @resultBuilder is primarily encountered through @ViewBuilder, a specialized result builder designed to construct view hierarchies. SwiftUI leverages @ViewBuilder to enable developers to write declarative UI code that is readable and maintainable.

How ViewBuilder Works

In SwiftUI, views are often defined using closures that describe their content. For example:

VStack {
    Text("Hello")
    Text("World")
}

The VStack initializer accepts a closure annotated with @ViewBuilder, which processes the sequence of views (Text("Hello") and Text("World")) and combines them into a single view hierarchy. ViewBuilder implements resultBuilder methods to handle view composition, aggregating multiple views into a cohesive structure, such as a tuple or array-like representation.

ViewBuilder vs. Methods and Properties: Returning some View

Methods or properties annotated with @ViewBuilder differ from those returning some View. A method or property returning some View produces a single, opaque view type. For example:

var content: some View {
    Text("Single View")
}

This returns a single Text view. In contrast, a @ViewBuilder-annotated method or property can handle a closure containing multiple views and combine them into a single view hierarchy:

@ViewBuilder
var content: some View {
    Text("First")
    Text("Second")
}

Here, @ViewBuilder enables a declarative syntax where multiple views are written sequentially without explicit composition, producing a single view hierarchy. This contrasts with a plain some View return type, which requires a single view expression. The @ViewBuilder approach is what makes SwiftUI’s DSL-like syntax possible, allowing developers to focus on describing the UI rather than managing view composition.

Practical Examples of resultBuilder Outside SwiftUI

While @ViewBuilder is a key use case, @resultBuilder is a general-purpose feature applicable to various domains. Below are two practical examples demonstrating its use outside SwiftUI, with a robust validation example that avoids previous type mismatch errors.

Example 1: Building SQL Queries

A common use case for resultBuilder is constructing SQL queries in a type-safe, declarative manner:

@resultBuilder
struct SQLQueryBuilder {
    static func buildBlock(_ components: String...) -> String {
        components
        .joined(separator: " ")
        .trimmingCharacters(in: .whitespaces)
    }
    
    static func buildExpression(_ expression: String) -> String {
        expression
    }
    
    static func buildOptional(_ component: String?) -> String {
        component ?? ""
    }
    
    static func buildEither(first: String) -> String {
        first
    }
    
    static func buildEither(second: String) -> String {
        second
    }
}

@SQLQueryBuilder
func buildQuery(_ content: () -> String) -> String {
    content()
}

let query = buildQuery {
    "SELECT *"
    "FROM users"
    "WHERE age > 18"
}

print(query) // Output: SELECT * FROM users WHERE age > 18

This implementation uses SQLQueryBuilder to combine multiple string expressions into a single SQL query string. The buildBlock method joins components with spaces and trims extra whitespace, while methods like buildOptional and buildEither ensure the builder handles optional expressions and conditional logic (e.g., if statements) robustly. The @SQLQueryBuilder attribute allows the closure to be written in a clean, declarative style, making query construction intuitive and reducing boilerplate.

Example 2: Configuring Validation Rules

Another practical application is building a validation system where rules are defined declaratively. The following example uses a Validator struct to compose validation rules for user input, such as email and name validation, using a resultBuilder to ensure type-safe and expressive rule composition:

enum ValidationError: Error, LocalizedError {
    case emptyField
    case invalidEmail
    case shortPassword
    case numbersNotAllowed
    case custom(String)
    
    var errorDescription: String? {
        switch self {
        case .emptyField: "Field cannot be empty"
        case .invalidEmail: "Invalid email format"
        case .shortPassword: "Password must be at least 8 characters"
        case .numbersNotAllowed: "Numbers are not allowed"
        case .custom(let message): message
        }
    }
}

protocol ValidationRule {
    func validate(_ input: String) throws
}

struct NotEmptyRule: ValidationRule {
    func validate(_ input: String) throws {
        if input.isEmpty {
            throw ValidationError.emptyField
        }
    }
}

struct EmailRule: ValidationRule {
    func validate(_ input: String) throws {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        if !predicate.evaluate(with: input) {
            throw ValidationError.invalidEmail
        }
    }
}

struct MinLengthRule: ValidationRule {
    let minLength: Int
    
    func validate(_ input: String) throws {
        if input.count < minLength {
            throw ValidationError.shortPassword
        }
    }
}

struct NoNumbersRule: ValidationRule {
    func validate(_ input: String) throws {
        if input.rangeOfCharacter(from: .decimalDigits) != nil {
            throw ValidationError.numbersNotAllowed
        }
    }
}

@resultBuilder
struct ValidationBuilder {
    static func buildBlock(_ components: ValidationRule...) -> [ValidationRule] {
        components
    }
    
    static func buildOptional(_ component: [ValidationRule]?) -> [ValidationRule] {
        component ?? []
    }
    
    static func buildEither(first component: [ValidationRule]) -> [ValidationRule] {
        component
    }
    
    static func buildEither(second component: [ValidationRule]) -> [ValidationRule] {
        component
    }
}

struct Validator {
    private let rules: [ValidationRule]
    
    init(@ValidationBuilder rules: () -> [ValidationRule]) {
        self.rules = rules()
    }
    
    func validate(_ input: String) throws {
        for rule in rules {
            try rule.validate(input)
        }
    }
}

func validateUserInput() {
    let emailValidator = Validator {
        NotEmptyRule()
        EmailRule()
    }
    
    let nameValidator = Validator {
        NotEmptyRule()
        NoNumbersRule()
        MinLengthRule(minLength: 2)
    }
    
    do {
        try emailValidator.validate("test@example.com") // Success
        try emailValidator.validate("invalid.email") // Error: Invalid email format
        try nameValidator.validate("John") // Success
        try nameValidator.validate("J0hn") // Error: Numbers are not allowed
        try nameValidator.validate("") // Error: Field cannot be empty
    } catch {
        print("Validation error: \(error.localizedDescription)")
    }
}

This example defines a Validator struct that uses @ValidationBuilder to compose validation rules for user input, such as email and name fields. The ValidationRule protocol and its implementations (NotEmptyRule, EmailRule, etc.) define specific validation logic, throwing descriptive errors if the input is invalid. The ValidationBuilder combines multiple rules into an array, supporting optional and conditional rules via buildOptional and buildEither. The Validator struct applies these rules to input strings, making it ideal for form validation in real-world applications, such as ensuring valid email addresses or names without numbers.

Why Use resultBuilder?

The @resultBuilder attribute offers several benefits:

1.  Readability: It enables a declarative syntax that is concise and natural, reducing boilerplate code.

2.  Flexibility: It can be applied to diverse domains, from UI construction to query building and validation.

3.  Type Safety: By leveraging Swift’s type system, resultBuilder ensures that composed components are valid and correctly typed.

However, developers must carefully design the builder’s static methods to handle edge cases, such as optional components or conditional logic. Performance considerations are also important when combining large numbers of components, and concurrency rules (e.g., actor isolation) must be respected in SwiftUI or other main-actor-bound contexts.

Conclusion

The @resultBuilder attribute is a versatile feature in Swift that powers SwiftUI’s declarative syntax through @ViewBuilder and enables custom DSLs in other domains. By understanding how @ViewBuilder simplifies view composition in SwiftUI and applying @resultBuilder to practical scenarios like SQL query construction and validation rule aggregation, developers can create cleaner, more expressive code.