Skip to content

ynishi/lshape

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

lshape

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)

Why lshape

  • 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: :describe attaches a doc string for reflection / codegen (not an error-message override), T.ref resolves names against a registry instead of capturing a closure à la types.proxy, and the transform / custom combinators 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 ---@class declarations for editor tooling and type-checkers.

Quick start

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)

Combinators (lshape.t)

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.

Validation (lshape.check)

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 var
  • check.check returns (true) on success, or (false, reason) on failure. reason uses a JSONPath-ish form with $ as the root, e.g. "shape violation at $.points[3].x: expected number, got string".
  • check.assert is the throwing variant. If you pass a string, it is looked up in the registry.
  • check.assert_dev is 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.

Reflection (lshape.reflect)

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.

LuaCATS codegen (lshape.luacats)

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).

Configuration

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")

A note on mutability

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.

Installation

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.

Testing

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 only

Or 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)

Origin

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.

Acknowledgements

  • 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.discriminated mirrors z.discriminatedUnion, T.ref resolution parallels z.lazy, and array_of(optional(T)) rendering as (T|nil)[] follows Zod's .optional().array() convention.

License

Dual-licensed under either of

at your option.

Contribution

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.

About

A small, ergonomic Schema-as-Data validator + LuaCATS codegen for Lua (5.1/5.2/5.3/5.4/LuaJIT). Pure Lua, zero deps.

Resources

License

Apache-2.0, MIT licenses found

Licenses found

Apache-2.0
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks

Packages

 
 
 

Contributors