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
- /guides/lua-metatables/ — covers
__index,__call,__newindex, and metatable inheritance - /guides/lua-closures/ — closures, upvalues, and the fundamentals this guide relies on
- /guides/lua-lpeg-parsing/ — LPeg, the parsing expression grammar library, for when tables and closures are not enough