Tableshape
Test the shape or structure of a Lua table, inspired by React.PropTypes & LPeg
Install / Use
/learn @leafo/TableshapeREADME
tableshape
A Lua library for verifying the shape (schema, structure, etc.) of a table, and transforming it if necessary. The type checking syntax is inspired by the PropTypes module of React. Complex types & value transformations can be expressed using an operator overloading syntax similar to LPeg.
Install
$ luarocks install tableshape
Quick usage
local types = require("tableshape").types
-- define the shape of our player object
local player_shape = types.shape{
class = types.one_of{"player", "enemy"},
name = types.string,
position = types.shape{
x = types.number,
y = types.number,
},
inventory = types.array_of(types.shape{
name = types.string,
id = types.integer
}):is_optional()
}
-- create a valid object to test the shape with
local player = {
class = "player",
name = "Lee",
position = {
x = 2.8,
y = 8.5
},
}
-- verify that it matches the shape
assert(player_shape(player))
-- let's break the shape to see the error message:
player.position.x = "heck"
assert(player_shape(player))
-- error: field `position`: field `x`: got type `string`, expected `number`
Transforming
A malformed value can be repaired to the expected shape by using the transformation operator and method. The input value is cloned and modified before being returned.
local types = require("tableshape").types
-- a type checker that will coerce a value into a number from a string or return 0
local number = types.number + types.string / tonumber + types.any / 0
number:transform(5) --> 5
number:transform("500") --> 500
number:transform("hi") --> 0
number:transform({}) --> 0
Because type checkers are composable objects, we can build more complex types out of existing types we've written:
-- here we reference our transforming number type from above
local coordinate = types.shape {
x = number,
y = number
}
-- a compound type checker that can fill in missing values
local player_shape = types.shape({
name = types.string + types.any / "unknown",
position = coordinate
})
local bad_player = {
position = {
x = "234",
y = false
}
}
local fixed_player = player_shape:transform(bad_player)
-- fixed_player --> {
-- name = "unknown",
-- position = {
-- x = 234,
-- y = 0
-- }
-- }
Tutorial
To load the library require it. The most important part of the library is the
types table, which will give you acess to all the type checkers
local types = require("tableshape").types
You can use the types table to check the types of simple values, not just
tables. Calling the type checker like a function will test a value to see if it
matches the shape or type. It returns true on a match, or nil and the error
message if it fails. (This is done with the __call metamethod, you can also
use the check_value method directly)
types.string("hello!") --> true
types.string(777) --> nil, expected type "string", got "number"
You can see the full list of the available types below in the reference.
The real power of tableshape comes from the ability to describe complex types
by nesting the type checkers.
Here we test for an array of numbers by using array_of:
local numbers_shape = types.array_of(types.number)
assert(numbers_shape({1,2,3}))
-- error: item 2 in array does not match: got type `string`, expected `number`
assert(numbers_shape({1,"oops",3}))
Note: The type checking is strict, a string that looks like a number,
"123", is not a number and will trigger an error!
The structure of a generic table can be tested with types.shape. It takes a
mapping table where the key is the field to check, and the value is the type
checker:
local object_shape = types.shape{
id = types.number,
name = types.string:is_optional(),
}
-- success
assert(object_shape({
id = 1234,
name = "hello world"
}))
-- sucess, optional field is not there
assert(object_shape({
id = 1235,
}))
-- error: field `id`: got type `nil`, expected `number`
assert(object_shape({
name = 424,
}))
The is_optional method can be called on any type checker to return a new type
checker that can also accept nil as a value. (It is equivalent to t + types['nil'])
If multiple fields fail the type check in a shape, the error message will contain all the failing fields
You can also use a literal value to match it directly: (This is equivalent to using types.literal(v))
local object_shape = types.shape{
name = "Cowcat"
}
-- error: field `name` expected `Cowcat`
assert(object_shape({
name = "Cowdog"
}))
The one_of type constructor lets you specify a list of types, and will
succeed if one of them matches. (It works the same as the + operator)
local func_or_bool = types.one_of { types.func, types.boolean }
assert(func_or_bool(function() end))
-- error: expected type "function", or type "boolean"
assert(func_or_bool(2345))
It can also be used with literal values as well:
local limbs = types.one_of{"foot", "arm"}
assert(limbs("foot")) -- success
assert(limbs("arm")) -- success
-- error: expected "foot", or "arm"
assert(limbs("baseball"))
The pattern type can be used to test a string with a Lua pattern
local no_spaces = types.pattern "^[^%s]*$"
assert(no_spaces("hello!"))
-- error: doesn't match pattern `^[^%s]*$`
assert(no_spaces("oh no!"))
These examples only demonstrate some of the type checkers provided. You can see all the other type checkers in the reference below.
Type operators
Type checker objects have the operators *, +, and / overloaded to provide
a quick way to make composite types.
*— The all of (and) operator, both operands must match.+— The first of (or) operator, the operands are checked against the value from left to right/— The transform operator, when using thetransformmethod, the value will be converted by what's to the right of the operator%— The transform with state operator, same as transform, but state is passed as second argument
The 'all of' operator
The all of operator checks if a value matches multiple types. Types are
checked from left to right, and type checking will abort on the first failed
check. It works the same as types.all_of.
local s = types.pattern("^hello") * types.pattern("world$")
s("hello 777 world") --> true
s("good work") --> nil, "doesn't match pattern `^hello`"
s("hello, umm worldz") --> nil, "doesn't match pattern `world$`"
The 'first of' operator
The first of operator checks if a value matches one of many types. Types
are checked from left to right, and type checking will succeed on the first
matched type. It works the same as types.one_of.
Once a type has been matched, no additional types are checked. If you use a
greedy type first, like types.any, then it will not check any additional
ones. This is important to realize if your subsequent types have any side
effects like transformations or tags.
local s = types.number + types.string
s(44) --> true
s("hello world") --> true
s(true) --> nil, "no matching option (got type `boolean`, expected `number`; got type `boolean`, expected `string`)"
The 'transform' operator
In type matching mode, the transform operator has no effect. When using the
transform method, however, the value will be modified by a callback or
changed to a fixed value.
The following syntax is used: type / transform_callback --> transformable_type
local t = types.string + types.any / "unknown"
The proceeding type can be read as: "Match any string, or for any other type, transform it into the string 'unknown'".
t:transform("hello") --> "hello"
t:transform(5) --> "unknown"
Because this type checker uses types.any, it will pass for whatever value is
handed to it. A transforming type can fail also fail, here's an example:
local n = types.number + types.string / tonumber
n:transform("5") --> 5
n:transform({}) --> nil, "no matching option (got type `table`, expected `number`; got type `table`, expected `string`)"
The transform callback can either be a function, or a literal value. If a function is used, then the function is called with the current value being transformed, and the result of the transformation should be returned. If a literal value is used, then the transformation always turns the value into the specified value.
A transform function is not a predicate, and can't directly cause the type
checking to fail. Returning nil is valid and will change the value to nil.
If you wish to fail based on a function you can use the custom type or chain
another type checker after the transformation:
-- this will fail unless `tonumber` returns a number
local t = (types.string / tonumber) * types.number
t:transform("nothing") --> nil, "got type `nil`, expected `number`"
A common pattern for repairing objects involves testing for the types you know
how to fix followed by + types.any, followed by a type check of the final
type you want:
Here we attempt to repair a value to the expected format for an x,y coordinate:
local types = require("tableshape").types
local str_to_coord = types.string / function(str)
local x,y = str:match("(%d+)[^%d]+(%d+)")
if not x then return end
return {
x = tonumber(x),
y = tonumber(y)
}
end
local array_to_coord = types.shape{types.number, types.number} / function(a)
return {
x = a[1],
y = a[2]
}
end
local cord = (str_to_coord + array_to_coord + types.any) * types.shape {
x = types.number,
y = types.number
}
cord:transform("100,200") --> { x = 100, y = 200}
c
