luaguides

Building Domain-Specific Languages in Lua

A domain-specific language is a mini-language tailored to a specific problem space. In Lua, you do not need a parser generator to build one. Lua’s table syntax, first-class functions, and metatable system combine into a surprisingly capable DSL-building toolkit. You can create fluent, readable APIs that read almost like a custom language.

This guide builds three progressively more sophisticated DSLs: a configuration DSL, a validation DSL, and a query DSL. Each one uses different Lua features to get the job done.

What Makes Lua Good for DSLs

Lua tables are flexible enough to represent nested structure without ceremony. Functions are values — you can pass them around, store them in tables, and return them from other functions. Closures capture their environment. Metatables let you intercept operators like __call, __index, and __newindex. Together these features mean you can build expressive APIs with remarkably little code.

The core idea across all DSLs in this guide is the same: represent the DSL’s statements as tables or functions, and use metatables to give them useful behaviour.

A Configuration DSL

The simplest DSL is one that builds a data structure through a fluent interface. You want code that reads like this:

local config = Config {
    server = {
        host = "localhost",
        port = 8080
    },
    database = {
        enabled = true,
        name = "myapp"
    }
}

Without a DSL, you might use nested tables directly. But you can add defaults, validation, and computed fields by wrapping the table construction in a function and applying metatable logic after the fact.

local Config = {}

function Config.build(raw)
    local self = setmetatable({}, { __index = Config })

    raw.server = raw.server or {}
    raw.server.host = raw.server.host or "127.0.0.1"
    raw.server.port = raw.server.port or 3000

    raw.database = raw.database or {}
    raw.database.name = raw.database.name or "default_db"

    self._raw = raw
    return self
end

function Config:get_server_host()
    return self._raw.server.host
end

function Config:get_server_port()
    return self._raw.server.port
end

function Config:is_database_enabled()
    return self._raw.database.enabled == true
end

-- Usage
local app = Config.build({
    server = { port = 9000 },
    database = { enabled = false, name = "prod" }
})

print(app:get_server_host())        -- 127.0.0.1 (default)
print(app:get_server_port())       -- 9000 (overridden)
print(app:is_database_enabled())  -- false

The Config.build() function receives a plain table and wraps it. Defaults are applied before the table is stored. Accessor methods are defined on the Config table’s metatable. This pattern separates the input from the representation.

A Validation DSL

A validation DSL lets you express rules declaratively:

local check = Validator {
    name  = is_string() + min_len(2) + max_len(50),
    age   = is_number() + min(0) + max(150),
    email = is_string() + matches_pattern("[^@]+@[^@]+")
}

local result = check { name = "Alice", age = 30, email = "alice@example.com" }
if not result.valid then
    print(result.error)  -- nil, no error
end

The + operator chains validators. The result is callable — you pass it a value and it returns either true or an error message.

Start with a Validator constructor that returns a callable table:

local Validator = {}
Validator.__index = Validator

function Validator.new(rules)
    local self = setmetatable({}, Validator)
    self._rules = rules
    return self
end

function Validator:__call(values)
    for field, rules in pairs(self._rules) do
        local value = values[field]

        for _, rule in ipairs(rules) do
            local ok, err = rule(value)
            if not ok then
                return false, string.format("%s: %s", field, err)
            end
        end
    end
    return true
end

Each rule is a function that returns ok, error_message. Combine them with +:

Validator.__add = function(left, right)
    -- left and right are both rules (functions or Validator chains)
    return function(value)
        local ok, err = left(value)
        if not ok then return false, err end
        local ok2, err2 = right(value)
        if not ok2 then return false, err2 end
        return true
    end
end

Now define individual validators:

local function is_string()
    return function(value)
        if type(value) == "string" then
            return true
        end
        return false, "must be a string"
    end
end

local function min_len(n)
    return function(value)
        if type(value) == "string" and #value >= n then
            return true
        end
        return false, "too short"
    end
end

local function max_len(n)
    return function(value)
        if type(value) == "string" and #value <= n then
            return true
        end
        return false, "too long"
    end
end

local function is_number()
    return function(value)
        if type(value) == "number" then return true end
        return false, "must be a number"
    end
end

local function min(n)
    return function(value)
        if type(value) == "number" and value >= n then return true end
        return false, "below minimum"
    end
end

local function max(n)
    return function(value)
        if type(value) == "number" and value <= n then return true end
        return false, "above maximum"
    end
end

local function matches_pattern(pattern)
    return function(value)
        if type(value) == "string" and string.match(value, pattern) then
            return true
        end
        return false, "does not match pattern"
    end
end

When you write is_string() + min_len(2), Validator.__add receives two rule functions and returns a new function that runs them both. This chains indefinitely.

A Query DSL

A query DSL builds SQL-like queries from Lua tables. It reads like this:

local q = Query.select({ "id", "name", "email" })
              .from("users")
              .where("active = 1")
              .order_by("name")
              .limit(10)

print(q:to_sql())
-- SELECT id, name, email FROM users WHERE active = 1 ORDER BY name LIMIT 10

Each method returns the query object itself, so calls chain. The to_sql() method concatenates the accumulated parts.

local Query = {}
Query.__index = Query

function Query.select(fields)
    local self = setmetatable({}, Query)
    self._fields = fields
    self._from = nil
    self._where = nil
    self._order_by = nil
    self._limit = nil
    return self
end

function Query.from(self, table_name)
    self._from = table_name
    return self
end

function Query.where(self, condition)
    self._where = condition
    return self
end

function Query.order_by(self, field)
    self._order_by = field
    return self
end

function Query.limit(self, n)
    self._limit = n
    return self
end

function Query.to_sql(self)
    local fields = table.concat(self._fields, ", ")
    local sql = string.format("SELECT %s FROM %s", fields, self._from or "")

    if self._where then
        sql = sql .. " WHERE " .. self._where
    end

    if self._order_by then
        sql = sql .. " ORDER BY " .. self._order_by
    end

    if self._limit then
        sql = sql .. " LIMIT " .. tostring(self._limit)
    end

    return sql
end

Because each method returns self, the calls chain automatically. The table-based structure makes it easy to add new clauses — you store the value in _where, _order_by, etc., and to_sql() assembles the final string.

You can extend this further: a join() method, an insert_into() builder, or a set() method for updates. The pattern scales because the object carries its state in a table and each method is a simple transformation of that state.

Callable Tables

A powerful DSL technique in Lua is making a table callable. You do this by setting __call in the metatable. A callable table looks and behaves like a function, but it also has named fields.

local function route(method, path, handler)
    return setmetatable({
        method = method,
        path = path,
        handler = handler
    }, {
        __call = function(self, req)
            return self.handler(req)
        end
    })
end

local get_users = route("GET", "/users", function(req)
    return { status = 200, body = '{"users": []}' }
end)

-- Looks like a function
local response = get_users({ url = "/users", method = "GET" })
print(response.body)  -- {"users": []}

-- But also has structured data
print(get_users.method)   -- GET
print(get_users.path)    -- /users

This is the pattern behind many Lua web frameworks. The returned object is a route — it has fields describing what it matches, but it is also callable.

Builder Pattern with __newindex

You can use __newindex to catch attribute assignments and accumulate them into a table automatically:

local Builder = {}
Builder.__index = Builder

function Builder.new()
    local self = setmetatable({}, {
        __index = function(t, k)
            return rawget(Builder, k) or t._state[k]
        end,
        __newindex = function(t, k, v)
            if rawget(Builder, k) then
                rawset(t, k, v)
            else
                t._state[k] = v
            end
        end
    })
    self._state = {}
    return self
end

local b = Builder.new()
b.username = "alice"       -- goes into _state
b.password = "secret123"   -- goes into _state
b.build = function() end  -- goes directly onto the object

print(b._state.username)   -- alice
print(b.build)             -- function

This pattern is rarely needed — normal table assignment works fine in most DSLs. But when you want to distinguish between user-provided data and method definitions, __newindex gives you that control.

Common Pitfalls

Mixing + with non-validators. The Validator.__add implementation assumes both operands are rule functions. If someone accidentally writes is_string() + "something", the code will error. Validate operands with type() checks or wrap the result in a table with a type tag.

Long chains with closures capturing loop variables. Lua closures capture variables by reference, not by value. In a loop, if you create closures inside the loop and they reference the loop variable, they all share the same variable slot:

for i = 1, 3 do
    local x = i  -- capture current value
    buttons[i] = function() print(x) end
end
buttons[1]()  -- 1

Without the local x = i assignment, all three closures would print 4.

Metatable conflicts. When a DSL object has a metatable, pairs() and next() will iterate over the metatable’s __index values too, not just the object’s own fields. Use rawget() and rawset() when you need to work with the object without invoking metamethods.

See Also