Domain-Specific Languages (DSLs) in Swift: Build Clearer APIs

Domain-specific languages in Swift let you design APIs that read like a small language for one problem domain, such as building views, menus, rules, or configuration trees. Instead of forcing users to assemble everything through noisy imperative code, a Swift DSL can make intent obvious and reduce boilerplate.

Quick answer: In Swift, DSLs are usually built with ordinary language features such as functions, closures, enums, and especially @resultBuilder. The goal is not to create a separate language, but to shape Swift code so it feels declarative and domain-focused.

Difficulty: Intermediate

Helpful to know first: You'll understand this better if you know Swift closures, basic types like structs and enums, and how function arguments work.

1. What Is Domain-Specific Language in Swift?

A domain-specific language, or DSL, is a way of writing code that is tailored to a narrow problem space. In Swift, a DSL is usually not a new language parser; it is a carefully designed API that makes one task feel natural to express.

SwiftUI is the most familiar example, but the same idea applies to many custom APIs. A DSL should feel like a concise language for one problem, not like general-purpose Swift written with extra punctuation.

2. Why DSLs Matter

DSLs matter because they can turn complex object assembly into code that is easier to read, easier to review, and harder to misuse. When the API matches the mental model of the domain, developers spend less time translating between business concepts and implementation details.

They are especially useful when code naturally has a tree shape, a sequence of rules, or a collection of related declarations. In those cases, a DSL can remove layers of temporary variables, factory calls, and repetitive method chaining.

They are not always the right choice. If an API is simple, a DSL may add more magic than value. A good DSL improves clarity for the intended task without hiding important behavior.

3. Basic Syntax or Core Idea

Swift DSLs are usually built from familiar features. The most common building blocks are closures, functions that accept closures, enums, protocols, generics, and @resultBuilder.

Minimal builder-style example

The following example shows a tiny tree-building DSL for menu items. The public API reads like a nested description of the menu instead of a series of imperative append calls.

@resultBuilder
enum MenuBuilder {
    static func buildBlock(_ components: MenuItem...) -> [MenuItem] {
        components
    }
}

struct MenuItem {
    let title: String
    let children: [MenuItem]

    init(_ title: String, children: [MenuItem] = []) {
        self.title = title
        self.children = children
    }
}

func makeMenu(@MenuBuilder _ content: () -> [MenuItem]) -> [MenuItem] {
    content()
}

let menu = makeMenu {
    MenuItem("File", children: [
        MenuItem("New"),
        MenuItem("Open")
    ])
    MenuItem("Edit", children: [
        MenuItem("Cut"),
        MenuItem("Copy")
    ])
}

This code shows the core pattern: the caller writes a nested block, and the builder converts that block into structured data.

4. Step-by-Step Examples

Example 1: Building a configuration object

A DSL can collect related settings in a readable block. This is useful when a configuration has many optional parts.

struct ServerConfig {
    let host: String
    let port: Int
    let useTLS: Bool
}

func configureServer(host: String, port: Int, useTLS: Bool) -> ServerConfig {
    ServerConfig(host: host, port: port, useTLS: useTLS)
}

let config = configureServer(host: "api.example.com", port: 443, useTLS: true)

This is a simple builder-style API, even without a custom builder. It groups the domain terms together and makes the call site self-explanatory.

Example 2: A lightweight rule DSL

Rules often read better when expressed in a declarative way. Here, we model a rule as a description plus a predicate.

struct Rule {
    let name: String
    let isAllowed: (Int) -> Bool
}

func rule(_ name: String, when condition: @escaping (Int) -> Bool) -> Rule {
    Rule(name: name, isAllowed: condition)
}

let evenRule = rule("Even numbers only") { value in
    value % 2 == 0
}

The function name and closure label shape the API into a mini language that reads like intent, not implementation.

Example 3: Using a result builder for nested content

A result builder becomes more powerful when it accepts multiple statements and nested structures. This is the pattern most people mean when they talk about Swift DSLs.

@resultBuilder
enum TextBuilder {
    static func buildBlock(_ components: String...) -> [String] {
        components
    }
}

func makeParagraph(@TextBuilder _ content: () -> [String]) -> String {
    content().joined(separator: " ")
}

let paragraph = makeParagraph {
    "Swift"
    "DSLs"
    "can"
    "improve"
    "readability."
}

This example shows the structure that Swift expands behind the scenes into a builder-generated result.

Example 4: Creating a fluent chain without overcomplicating it

Sometimes a DSL is just a carefully designed chain of methods. This is still valid when the domain benefits from sentence-like code.

struct Query {
    var filters: [String] = []

    func filter(_ clause: String) -> Query {
        var copy = self
        copy.filters.append(clause)
        return copy
    }
}

let query = Query()
    .filter("status = 'active'")
    .filter("age >= 18")

Fluent chaining is not automatically a DSL, but it becomes one when the method names and return types model the domain clearly.

5. Practical Use Cases

These use cases share one thing: the data is naturally hierarchical or declarative, so a builder-style API improves readability.

6. Common Mistakes

Mistake 1: Making the API too magical

Some DSLs hide too much logic behind a compact syntax. If readers cannot tell what is created, the DSL becomes harder to maintain than ordinary Swift.

Problem: The API looks elegant, but it obscures important behavior such as defaults, side effects, or validation rules.

let config = makeConfiguration {
    "production"
    "fast"
    "safe"
}

Fix: Prefer explicit labels when the meaning of each value matters.

let config = makeConfiguration {
    environment("production")
    performanceMode("fast")
    safetyMode("safe")
}

The corrected version works better because the call site explains what each value means.

Mistake 2: Ignoring type constraints in builders

Result builders are type-checked by the compiler, so the expressions inside the closure must match the builder's expected component type.

Problem: A builder that accepts only String values will fail if you place a different type inside the block. The compiler may report that it cannot convert the expression's type.

let text = makeParagraph {
    "Hello"
    42
}

Fix: Convert the value or change the builder's component type so the API matches the domain.

let text = makeParagraph {
    "Hello"
    String(42)
}

The fixed version works because every statement in the builder now produces the expected component type.

Mistake 3: Overusing nested closures when a simple function is enough

Not every configuration needs a DSL. If the structure is flat and the domain is simple, builder syntax can be more verbose than a direct function call.

Problem: A DSL-style API can make trivial tasks harder to read when it adds unnecessary nesting and indirection.

let message = buildMessage {
    title {
        "Welcome"
    }
    body {
        "Hello!"
    }
}

Fix: Use a straightforward API when the domain does not need nesting.

let message = buildMessage(title: "Welcome", body: "Hello!")

The simpler version works better because it matches the complexity of the problem instead of inflating it.

7. Best Practices

Practice 1: Make the domain visible in the API

A good DSL should use names from the problem space, not generic placeholders. Readers should be able to guess the meaning of the code without opening documentation.

func accessPolicy(_ rules: [Rule]) -> AccessPolicy {
    // domain-focused API
    AccessPolicy(rules: rules)
}

This approach works because the function name and parameter type say what the code is for.

Practice 2: Keep the surface area small

DSLs become harder to learn when they expose too many entry points. Start with the smallest API that covers the core use case and add only what is needed.

struct LoggerConfig {
    let level: String
    let destination: String
}

A compact model like this is easier to expose through a DSL than a large class with many unrelated knobs.

Practice 3: Prefer compile-time structure over string parsing

When possible, encode the DSL in Swift types rather than parsing custom text at runtime. You get compiler feedback, autocompletion, and safer refactoring.

enum Environment {
    case development
    case staging
    case production
}

This is better than storing environment names as arbitrary strings because the compiler can catch invalid values.

8. Limitations and Edge Cases

A DSL is best when the structure is stable and the readability gain outweighs the cost of abstraction.

9. Practical Mini Project

Let us build a small task-list DSL that models sections and tasks. The goal is to create an API that reads like a compact project outline.

@resultBuilder
enum TaskBuilder {
    static func buildBlock(_ components: TaskNode...) -> [TaskNode] {
        components
    }
}

protocol TaskNode {
    func render(indent: String) -> String
}

struct Task: TaskNode {
    let title: String

    func render(indent: String = "") -> String {
        indent + "- " + title
    }
}

struct Section: TaskNode {
    let title: String
    let children: [TaskNode]

    init(_ title: String, @TaskBuilder _ content: () -> [TaskNode]) {
        self.title = title
        self.children = content()
    }

    func render(indent: String = "") -> String {
        let header = indent + title
        let body = children.map { $0.render(indent: indent + "  ") }.joined(separator: "\n")
        return [header, body].joined(separator: "\n")
    }
}

func makePlan(@TaskBuilder _ content: () -> [TaskNode]) -> [TaskNode] {
    content()
}

let plan = makePlan {
    Section("Sprint 1") {
        Task(title: "Design API")
        Task(title: "Implement parser")
    }

    Section("Sprint 2") {
        Task(title: "Write tests")
        Task(title: "Prepare release notes")
    }
}

for node in plan {
    print(node.render())
}

This mini project shows a realistic DSL shape: a top-level container, nested sections, and domain terms that read naturally. The result is still ordinary Swift, but the call site behaves like a small language for planning work.

10. Key Points

11. Practice Exercise

Expected output: A printed tree that shows each album title followed by its photos.

Hint: Define a protocol for nodes, then use a result builder that returns an array of those nodes.

Solution:

@resultBuilder
enum AlbumBuilder {
    static func buildBlock(_ components: AlbumNode...) -> [AlbumNode] {
        components
    }
}

protocol AlbumNode {
    func render(indent: String) -> String
}

struct Photo: AlbumNode {
    let name: String

    func render(indent: String) -> String {
        indent + "- Photo: " + name
    }
}

struct Album: AlbumNode {
    let title: String
    let items: [AlbumNode]

    init(_ title: String, @AlbumBuilder _ content: () -> [AlbumNode]) {
        self.title = title
        self.items = content()
    }

    func render(indent: String = "") -> String {
        let header = indent + "Album: " + title
        let body = items.map { $0.render(indent: indent + "  ") }.joined(separator: "\n")
        return [header, body].joined(separator: "\n")
    }
}

let vacationAlbum = Album("Vacation") {
    Photo(name: "Beach")
    Photo(name: "Sunset")
    Photo(name: "Mountains")
}

print(vacationAlbum.render())

This solution works because the builder collects typed nodes and the album renders them as a structured outline.

12. Final Summary

Swift DSLs are a way to make APIs read like a focused language for one domain. They rely on ordinary Swift features, especially closures and result builders, to model structure in a way that feels declarative and expressive.

The best DSLs remove friction without hiding meaning. If the domain is naturally hierarchical, repetitive, or rule-based, a DSL can be a strong fit. If the problem is simple or volatile, a plain API is often easier to understand and maintain.

As a next step, study Swift result builders in more depth and compare a small builder-based API with an equivalent plain function-based design. That comparison will make the tradeoffs of DSLs much easier to judge in real projects.