A small, ergonomic schema library for Lua tables. Runs on Lua 5.1, 5.2, 5.3, 5.4, and LuaJIT. Pure Lua, zero dependencies. Dual-licensed under MIT or Apache-2.0.
local lshape = require("lshape")
local T = lshape.t
local check = lshape.check
local User = T.shape({
name = T.string,
age = T.number,
email = T.string:is_optional():describe("RFC 5321 address"),
})
local ok, reason = check.check({ name = "Ada", age = 36 }, User)
assert(ok, reason)- tableshape-inspired combinator base. The combinator surface —
T.string,T.shape,T.array_of,T.one_of,T.map_of,:is_optional()— takes leafo/tableshape as its base reference. lshape is not a drop-in replacement, though::describeattaches a doc string for reflection / codegen (not an error-message override),T.refresolves names against a registry instead of capturing a closure à latypes.proxy, and thetransform/customcombinators are not (yet) provided. - Schema-as-Data. Following Malli's data-driven
schemas idea, every schema is a plain
Lua table whose internal state is stored in
rawget-readable fields (kind,prim,fields,inner,elem,values,open,doc,name,key,val,variants,tag). No closures, no hidden state. You can inspect a schema, serialize it, walk it, or codegen from it with ordinary table operations. - Persistable. Because there are no captured closures, schemas survive a JSON round-trip. Strip every metatable and validation behaviour does not change — this is covered by the test suite.
- LuaCATS codegen out of the box. Turn a dictionary of schemas into
---@classdeclarations for editor tooling and type-checkers.
local lshape = require("lshape")
local T = lshape.t
local check = lshape.check
-- Named-field records.
local Point = T.shape({
x = T.number,
y = T.number,
}, { open = false }) -- strict: reject extra keys
-- Arrays, optional fields, inline docs.
local Polyline = T.shape({
points = T.array_of(Point),
label = T.string:is_optional():describe("UI label"),
})
-- Enums.
local Colour = T.one_of({ "red", "green", "blue" })
-- Named references resolved against a registry.
local Canvas = T.shape({
bg = Colour,
shapes = T.array_of(T.ref("Polyline")),
})
check.default_registry = { Polyline = Polyline }
local ok, why = check.check(payload, Canvas)| Combinator | Purpose |
|---|---|
T.string / T.number / T.boolean / T.table / T.any |
Primitive leaves |
T.shape(fields, opts) |
Named-field record. opts.open defaults to true; pass { open = false } for strict records that reject unknown keys. |
T.partial(fields, opts) |
Sugar over T.shape that wraps every field in :is_optional() (idempotent on already-optional fields). |
T.array_of(elem) |
1-based dense array of elem. |
T.map_of(key, val) |
Arbitrary table whose keys and values are each validated. |
T.one_of(values) |
Enum of primitive literals (string / number / boolean). |
T.literal(value) |
Single-value alias over T.one_of({value}). |
T.any_of(variants) |
Untagged union of nested schemas; first-match wins at validation time (≥2 variants). |
T.discriminated(tag, variants) |
Tagged union over { [tag_value] = shape, ... }. |
T.pattern(pat) |
Lua-pattern-constrained string. Non-empty pattern required. |
T.ref(name) |
Lookup against a registry at validation time. |
s:is_optional() |
Wrap: nil is accepted. |
s:describe(doc) |
Wrap: attach a human-readable doc string. |
Combinators always return a fresh table; they never mutate their inputs.
T.shape and T.one_of additionally shallow-copy their input tables so
that later caller-side mutation does not leak into the schema.
local ok, reason = check.check(value, schema) -- ok, err-string
check.assert(value, schema, "where") -- throw on fail
check.assert(value, "User", "where") -- registry lookup
check.assert_dev(value, schema_or_name, "where") -- no-op unless dev
check.is_dev_mode() -- reads env varcheck.checkreturns(true)on success, or(false, reason)on failure.reasonuses a JSONPath-ish form with$as the root, e.g."shape violation at $.points[3].x: expected number, got string".check.assertis the throwing variant. If you pass a string, it is looked up in the registry.check.assert_devis a no-op when the dev env var (see below) is not"1", so it is safe to leave in hot paths during development.- A recursion-depth guard prevents pathological self-referential schemas from looping; non-pathological recursion (linked lists etc.) validates normally.
for _, entry in ipairs(reflect.fields(User)) do
print(entry.name, entry.schema.kind, entry.optional, entry.doc)
end
reflect.walk(User, function(node)
-- visit every sub-schema (DFS, root first)
end)reflect.fields normalises the :is_optional() / :describe(...)
wrappers so consumers see a single flat record per field.
local src = lshape.luacats.gen({
User = User,
Canvas = Canvas,
})
-- Writes ---@meta and ---@class blocks. Ship src as a .d.lua.Use class_for(name, schema, prefix) for a single class, or pass a
prefix to gen(shapes, prefix) to namespace generated class names
(e.g. "MyApp" → MyAppUser, MyAppCanvas).
Three module fields tune behaviour. All are plain data — assign them like any field; there are no builders or factories to call.
-- Registry used by T.ref(...) and by check.assert("Name", ...).
lshape.check.default_registry = { User = User, Canvas = Canvas }
-- Env var checked by assert_dev / is_dev_mode. Default "LSHAPE_CHECK".
lshape.check.dev_env_var = "MY_APP_CHECK"
-- Default class prefix for codegen. Default "" (no prefix).
-- Or pass explicitly: lshape.luacats.gen(shapes, "MyApp")Schemas are plain tables, so nothing stops you from poking at their
internal fields (schema.fields.age = T.string) or mutating a registry
at runtime. That is deliberate — it's what lets schemas be persisted,
rebound, and codegen-friendly. But lshape itself treats schemas as
immutable values: combinators always return new tables, and the
validator never writes to the schema it is given.
Please follow the same convention in your own code. Build schemas once, then read-only. If you need a variant, compose a new one with the combinators instead of editing a live schema. Mutating a schema that is already being validated against concurrently is undefined.
lshape is a single Lua package (lshape/). Drop it on your
package.path, or use your preferred mechanism (LuaRocks rockspec,
git submodule, vendored copy). There are no runtime dependencies.
Tests use a vendored copy of bjornbytes/lust
(MIT) under tests/vendor/lust.lua. A justfile is included:
just test # run every tests/test_*.lua
just test-one check # run tests/test_check.lua onlyOr invoke lua5.4 directly:
LUA_PATH="./?.lua;./?/init.lua;./tests/vendor/?.lua;;" \
lua5.4 -e "lust = require('lust'); dofile('tests/test_check.lua')"| File | Covers |
|---|---|
test_t.lua |
DSL combinators and construction-time guards |
test_check.lua |
Validator semantics, registry / dev-mode hooks, recursion guard |
test_reflect.lua |
reflect.fields and reflect.walk |
test_luacats.lua |
LuaCATS codegen and class-prefix handling |
test_persist.lua |
Schema-as-Data invariants (metatable-strip round-trip) |
lshape is extracted from the alc_shapes module in
algocline-bundled-packages,
where it evolved as an internal shape validator for host-specific
pipelines. The three host-coupling points — the default registry
(check.default_registry), the dev-mode env var (check.dev_env_var),
and the LuaCATS class prefix — have been parameterised so this repo is
the host-neutral core. The single commit in this repository reflects
the extraction squash, not the development history; design notes and
C* / EE* comment markers in the source trace back to issues in
algocline-bundled-packages.
- leafo/tableshape — base reference for lshape's combinator surface. See "Why lshape" for the intentional divergences.
- metosin/malli — source of the data-driven / schema-as-data idea that shapes lshape's internal representation.
- colinhacks/zod — reference for
several design decisions:
T.discriminatedmirrorsz.discriminatedUnion,T.refresolution parallelsz.lazy, andarray_of(optional(T))rendering as(T|nil)[]follows Zod's.optional().array()convention.
Dual-licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.