Introduction: When Versatility is the Enemy
\n\nImagine you are writing code for network configuration, log parsing, or describing business rules. Every time — repetitive constructs, tons of brackets, nested conditional statements, and painful reading of someone else's code. Sound familiar?
\n\nIn such situations, domain-specific languages (DSL) come to the rescue. This is not magic or reinventing the wheel — it's a way to make code so readable that it documents itself. In this article, we'll break down how to create your own DSL in Swift — powerful, elegant, and yet not requiring a compiler.
\n\nA DSL in Swift is not just a trendy iOS development fad. It's a tool used in SwiftUI, Combine, and even in backend libraries. You will learn to design languages that solve specific tasks: from data parsing to UI description. Let's go!
\n\nWhat is a DSL and Why Does a Developer Need It?
\n\nA Domain-Specific Language is a language tailored to one narrow area. Unlike a general-purpose language (like Swift or Python), a DSL speaks the language of your subject domain. For example, instead of:
\n\nlet config = NetworkConfig()\nconfig.host = "api.example.com"\nconfig.port = 443\nconfig.timeout = 30\n\nYou write:
\n\nlet config = networkConfig {\n host("api.example.com")\n port(443)\n timeout(30)\n}\n\nThe second option reads like instructions in a human language. This is the goal of a DSL — to reduce cognitive load and make code self-documenting.
\n\nIn Swift, creating a DSL most often uses:
\n- \n
- Result Builders — a new feature in Swift 5.4+ that allows building nested structures using declarative syntax. \n
- Functional chains — the classic builder pattern. \n
- Operators — operator overloading to create fluent interfaces. \n
But let's get straight to the point. We will create a DSL for parsing CSV files — a task that torments every developer at least once in their life.
\n\nExample 1: A Simple DSL for Network Configuration
\n\nLet's start with a basic example that demonstrates the mechanics. Suppose we need to describe HTTP requests. Instead of a bunch of parameters — a concise DSL.
\n\n// Define the request structure\nstruct HTTPRequest {\n var method: String = "GET"\n var path: String = "/"\n var headers: [String: String] = [:]\n var body: Data? = nil\n}\n\n// Create a DSL using Result Builder\n@resultBuilder\nstruct RequestBuilder {\n static func buildBlock(_ components: HTTPRequestModifier...) -> HTTPRequest {\n var request = HTTPRequest()\n for mod in components {\n mod.apply(&request)\n }\n return request\n }\n}\n\n// Request modifiers\nprotocol HTTPRequestModifier {\n func apply(_ request: inout HTTPRequest)\n}\n\nstruct MethodModifier: HTTPRequestModifier {\n let method: String\n func apply(_ request: inout HTTPRequest) {\n request.method = method\n }\n}\n\nstruct PathModifier: HTTPRequestModifier {\n let path: String\n func apply(_ request: inout HTTPRequest) {\n request.path = path\n }\n}\n\n// Helper functions\nfunc method(_ m: String) -> HTTPRequestModifier { MethodModifier(method: m) }\nfunc path(_ p: String) -> HTTPRequestModifier { PathModifier(path: p) }\n\n// Usage\n@RequestBuilder\nfunc buildGetRequest() -> HTTPRequest {\n method("GET")\n path("/users")\n}\n\nlet request = buildGetRequest()\nprint(request.method, request.path) // GET /users\n\nThis code is the foundation of any DSL in Swift. @resultBuilder collects all expressions inside the block and transforms them into a single structure. Important: each element is a modifier that changes the state.
Now let's complicate the task: add nesting and conditions.