local M = {}
local combinators = {}
local schema_mt = { __index = combinators }
local function is_schema(v)
return type(v) == "table" and rawget(v, "kind") ~= nil
end
function combinators:is_optional()
return setmetatable({ kind = "optional", inner = self }, schema_mt)
end
function combinators:describe(doc)
if type(doc) ~= "string" then
error("lshape.t: describe expects string doc", 2)
end
return setmetatable({ kind = "described", inner = self, doc = doc }, schema_mt)
end
M.string = setmetatable({ kind = "prim", prim = "string" }, schema_mt)
M.number = setmetatable({ kind = "prim", prim = "number" }, schema_mt)
M.boolean = setmetatable({ kind = "prim", prim = "boolean" }, schema_mt)
M.table = setmetatable({ kind = "prim", prim = "table" }, schema_mt)
M.any = setmetatable({ kind = "any" }, schema_mt)
function M.shape(fields, opts)
if type(fields) ~= "table" then
error("lshape.t: shape expects fields table as first argument", 2)
end
local copy = {}
for name, sub in pairs(fields) do
if type(name) ~= "string" then
error("lshape.t: shape field name must be string, got " .. type(name), 2)
end
if not is_schema(sub) then
error(string.format(
"lshape.t: shape field '%s' must be a schema (table with kind)", name), 2)
end
copy[name] = sub
end
local open
if opts == nil then
open = true
else
if type(opts) ~= "table" then
error("lshape.t: shape expects opts table as second argument", 2)
end
if opts.open == nil then
open = true
else
open = opts.open and true or false
end
end
return setmetatable({ kind = "shape", fields = copy, open = open }, schema_mt)
end
function M.partial(fields, opts)
if type(fields) ~= "table" then
error("lshape.t: partial expects fields table as first argument", 2)
end
local wrapped = {}
for name, sub in pairs(fields) do
if type(name) ~= "string" then
error("lshape.t: partial field name must be string, got " .. type(name), 2)
end
if not is_schema(sub) then
error(string.format(
"lshape.t: partial field '%s' must be a schema (table with kind)", name), 2)
end
if rawget(sub, "kind") == "optional" then
wrapped[name] = sub
else
wrapped[name] = sub:is_optional()
end
end
return M.shape(wrapped, opts)
end
function M.array_of(elem)
if not is_schema(elem) then
error("lshape.t: array_of expects a schema as argument", 2)
end
local probe = elem
while rawget(probe, "kind") == "described" do
probe = rawget(probe, "inner")
end
if rawget(probe, "kind") == "optional" then
error(
"lshape.t: array_of(optional(T)) is not allowed — " ..
"Lua's `#` cannot reliably validate arrays with nil holes. " ..
"Use array_of(T) (require dense) or model the nil-admission " ..
"at the enclosing field (e.g. T.array_of(T):is_optional()).",
2)
end
return setmetatable({ kind = "array_of", elem = elem }, schema_mt)
end
function M.one_of(values)
if type(values) ~= "table" then
error("lshape.t: one_of expects a values table as argument", 2)
end
local n = 0
for _ in pairs(values) do n = n + 1 end
if n == 0 then
error("lshape.t: one_of expects at least one value", 2)
end
for i = 1, n do
local v = values[i]
if v == nil then
error("lshape.t: one_of expects a 1-based dense array of values", 2)
end
local t = type(v)
if t ~= "string" and t ~= "number" and t ~= "boolean" then
error(string.format(
"lshape.t: one_of values must be string/number/boolean, got %s at index %d",
t, i), 2)
end
end
local seen = {}
local copy = {}
for i = 1, n do
local v = values[i]
local key = type(v) .. ":" .. tostring(v)
if seen[key] then
error(string.format(
"lshape.t: one_of has duplicate value %s at index %d",
(type(v) == "string") and string.format("%q", v) or tostring(v),
i), 2)
end
seen[key] = true
copy[i] = v
end
return setmetatable({ kind = "one_of", values = copy }, schema_mt)
end
function M.literal(value)
local t = type(value)
if t ~= "string" and t ~= "number" and t ~= "boolean" then
error(string.format(
"lshape.t: literal expects string/number/boolean, got %s", t), 2)
end
return M.one_of({ value })
end
function M.discriminated(tag, variants)
if type(tag) ~= "string" or tag == "" then
error("lshape.t: discriminated expects non-empty string tag", 2)
end
if type(variants) ~= "table" then
error("lshape.t: discriminated expects variants table", 2)
end
local copy = {}
local count = 0
for k, v in pairs(variants) do
if type(k) ~= "string" then
error("lshape.t: discriminated variant key must be string, got " .. type(k), 2)
end
if not is_schema(v) or rawget(v, "kind") ~= "shape" then
error(string.format(
"lshape.t: discriminated variant '%s' must be a shape schema", k), 2)
end
if rawget(rawget(v, "fields"), tag) == nil then
error(string.format(
"lshape.t: discriminated variant '%s' must declare the tag field '%s'",
k, tag), 2)
end
copy[k] = v
count = count + 1
end
if count == 0 then
error("lshape.t: discriminated expects at least one variant", 2)
end
return setmetatable({ kind = "discriminated", tag = tag, variants = copy }, schema_mt)
end
function M.pattern(pat)
if type(pat) ~= "string" then
error("lshape.t: pattern expects string, got " .. type(pat), 2)
end
if pat == "" then
error("lshape.t: pattern must not be empty (use T.string for any string)", 2)
end
return setmetatable({ kind = "pattern", pattern = pat }, schema_mt)
end
function M.ref(name)
if type(name) ~= "string" or name == "" then
error("lshape.t: ref expects non-empty string name", 2)
end
return setmetatable({ kind = "ref", name = name }, schema_mt)
end
function M.any_of(variants)
if type(variants) ~= "table" then
error("lshape.t: any_of expects a variants table as argument", 2)
end
local n = 0
for _ in pairs(variants) do n = n + 1 end
if n < 2 then
error("lshape.t: any_of expects at least two variants", 2)
end
local copy = {}
for i = 1, n do
local v = variants[i]
if v == nil then
error("lshape.t: any_of expects a 1-based dense array of variants", 2)
end
if not is_schema(v) then
error(string.format(
"lshape.t: any_of variant at index %d must be a schema", i), 2)
end
copy[i] = v
end
return setmetatable({ kind = "any_of", variants = copy }, schema_mt)
end
function M.map_of(key, val)
if not is_schema(key) then
error("lshape.t: map_of expects a schema as key argument", 2)
end
if not is_schema(val) then
error("lshape.t: map_of expects a schema as val argument", 2)
end
return setmetatable({ kind = "map_of", key = key, val = val }, schema_mt)
end
M._internal = {
schema_mt = schema_mt,
combinators = combinators,
is_schema = is_schema,
}
return M