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.
$ luarocks install tableshape
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`
A malformed table 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
-- }
-- }
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 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 thetransform
method, 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 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 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`)"
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:t({}) --> 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}
cord:transform({5, 23}) --> { x = 5, y = 23}
cord:transform({ x = 9, y = 10}) --> { x = 9, y = 10}
Tags can be used to extract specified values from a type as it's checked. A tag is only saved if the type it wraps matches.
local t = types.shape {
a = types.number:tag("x"),
b = types.number:tag("y"),
} + types.shape {
types.number:tag("x"),
types.number:tag("y"),
}
t({1,2}) --> { x = 1, y = 2}
t({a = 3, b = 9}) --> { x = 3, y = 9}
The results of tag matches are stored in the state object, an table that is passed throughout the entire type check. The state is returned at the end of a type check as the first return value on a successful match.
If no state is used, then true
is returned.
You can use scopes to nest tags into objects. A scope can be created with
types.scope
. A scope works by pushing a new state on the state stack. After
the scope is completed, it is assigned to the previous scope at the specified
name
local obj = types.shape {
id = types.string:tag("name"),
age = types.number
}
local many = types.array_of(types.scope(obj, { tag = "names[]"}))
many({
{ id = "leaf", age = 2000 },
{ id = "amos", age = 15 }
}) --> { names = {name = "leaf"}, {name = "amos"}}
In this example, we use the special []
syntax in the tag name to accumulate
all values that are tagged into an array. If the []
was left out, then each
tagged value would overwrite the previous.
If the tag of the types.scope
is left out, then an anonymous scope is used.
An anonymous scope is thrown away after the scope is exited. This style is
useful if you use state for a local transformation, and don't need those values
outside.
When calling a type object with the transform method you can modify transform
the input with callbacks until it matches the types you specify. This is done
with a combination of type operators, including the transform
operator.
For example, we can take pattern type checker for URLs that fixes the input:
local url_shape = types.pattern("^https?://") + types.string / function(val)
return "http://" .. val
end
If you pass a mutable object, like a table, then any transformations will be done on a copies of the data. The original object will not be modified. A new copy is always returned, even if no transformations take place.
url_shape:transform("https://itch.io") --> https://itch.io
url_shape:transform("leafo.net") --> http://leafo.net
url_shape:transform({}) --> nil, "no matching option (expected string for value; got type `table`)"
We can compose transformable type checkers. Now that we know how to fix a URL, we can fix an array of URLs:
local urls_array = types.array_of(url_shape)
local fixed_urls = urls_array:transform({
"https://itch.io",
"leafo.net",
{}
"www.streak.club",
})
The transform
method of the array_of
type will transform each value of the
array. A special property of the array_of
transform is to exclude any values
that get turned into nil
in the final output. You can use this to filter out
any bad data without having holes in your array. (You can override this with
the keep_nils
option.
local types = require("tableshape").types
Type constructors build a type checker configured by the parameters you pass. Here are all the available ones, full documentation is below.
types.shape
- checks the shape of a tabletypes.one_of
- checks if value matches one of the types providedtypes.pattern
- checks if Lua pattern matches valuetypes.array_of
- checks if value is array containing a typetypes.array_contains
- checks if value is an array that contains a type (short circuits by default)types.map_of
- checks if value is table that matches key and value typestypes.literal
- checks if value matches the provided value with==
types.custom
- lets you provide a function to check the typetypes.equivalent
- checks if values deeply compare to one anothertypes.range
- checks if value is between two other valuestypes.proxy
- dynamically load a type checker
Returns a type checker tests for a table where every key in table_dec
has a
type matching the associated value. The associated value can also be a literal
value.
local t = types.shape{
category = "object", -- matches the literal value `"object"`
id = types.number,
name = types.string
}
The following options are supported:
open
— The shape will accept any additional fields without failingextra_fields
— A type checker for use with extra keys. For each extra field in the table, the value{key = value}
is passed to theextra_fields
type checker. During transformation, the table can be transformed to change either the key or value. Transformers that returnnil
will clear the field. See below for examples. The extra keys shape can also use tags.
Examples with extra_fields
:
Basic type test for extra fields:
local t = types.shape({
name = types.string
}, {
extra_fields = types.map_of(types.string, types.number)
})
t({
name = "lee",
height = "10cm",
friendly = false,
}) --> nil, "field `height` value in table does not match: got type `string`, expected `number`"
A transform can be used on extra_fields
as well. In this example all extra fields are removed:
local t = types.shape({
name = types.string
}, {
extra_fields = types.any / nil
})
t:transform({
name = "amos",
color = "blue",
1,2,3
}) --> { name = "amos"}
Modifying the extra keys using a transform:
local types = require("tableshape").types
local t = types.shape({
name = types.string
}, {
extra_fields = types.map_of(
-- prefix all extra keys with _
types.string / function(str) return "_" .. str end,
-- leave values as is
types.any
)
})
t:transform({
name = "amos",
color = "blue"
}) --> { name = "amos", _color = "blue" }
The same as types.shape
but sets open = true
by default. This alias
function was added because open shape objects are common when using tableshape.
local types = require("tableshape").types
local t = types.partial {
name = types.string\tag "player_name"
}
t({
t: "character"
name: "Good Friend"
}) --> { player_name: "Good Friend" }
Returns a type checker that tests if the value is an array where each item matches the provided type.
local t = types.array_of(types.shape{
id = types.number
})
The following options are supported:
keep_nils
— By default, if a value is transformed into a nil then it won't be kept in the output array. If you need to keep these holes then set this option totrue
length
— Provide a type checker to be used on the length of the array. The length is calculated with the#
operator. It's typical to usetypes.range
to test for a range
Returns a type checker that tests if item_type
exists in the array. By
default, short_circuit
is enabled. It will search until it finds the first
instance of item_type
in the array then stop with a success. This impacts
transforming types, as only the first match will be transformed by default. To
process every entry in the array, set short_circuit = false
in the options.
local t = types.array_of(types.number)
t({"one", "two", 3, "four"}) --> true
t({"hello", true}) --> fails
The following options are supported:
short_circuit
— (defaulttrue
) Will stop scanning over the array if a single match is foundkeep_nils
— By default, if a value is transformed into a nil then it won't be kept in the output array. If you need to keep these holes then set this option totrue
Returns a type checker that tests for a table where every key and value matches the respective type checkers provided as arguments.
local t = types.map_of(types.string, types.any)
When transforming a map_of
, you can remove fields from the table by
transforming either the key or value to nil
.
-- this will remove all fields with non-string keys
local t = types.map_of(types.string + types.any / nil, types.any)
t:transform({
1,2,3,
hello = "world"
}) --> { hello = "world" }
Returns a type checker that tests if the value matches one of the provided types. A literal value can also be passed as a type.
local t = types.one_of{"none", types.number}
Returns a type checker that tests if a string matches the provided Lua pattern
local t = types.pattern("^#[a-fA-F%d]+$")
Returns a type checker that checks if value is equal to the one provided. When
using shape this is normally unnecessary since non-type checker values will be
checked literally with ==
. This lets you attach a repair function to a
literal check.
local t = types.literal "hello world"
assert(t("hello world") == true)
assert(t("jello world") == false)
Returns a type checker that calls the function provided to verify the value. The function will receive the value being tested as the first argument, and the type checker as the second.
The function should return true if the value passes, or nil
and an error
message if it fails.
local is_even = types.custom(function(val)
if type(val) == "number" then
if val % 2 == 0 then
return true
else
return nil, "number is not even"
end
else
return nil, "expected number"
end
end)
Returns a type checker that will do a deep compare between val and the input.
local t = types.equivalent {
color = {255,100,128},
name = "leaf"
}
-- although we're testing a different instance of the table, the structure is
-- the same so it passes
t {
name = "leaf"
color = {255,100,128},
} --> true
Creates a type checker that will check if a value is beween left
and right
inclusive. The type of the value is checked before doing the comparison:
passing a string to a numeric type checker will fail up front.
local nums = types.range 1, 20
local letters = types.range "a", "f"
nums(4) --> true
nums("c") --> true
nums("n") --> true
This checker works well with the length checks for strings and arrays.
The proxy type checker will execute the provided function, fn
, when called
and use the return value as the type checker. The fn
function must return a
valid tableshape type checker object.
This can be used to have types that circularly depend on one another, or handle
recursive types. fn
is called every time the proxy checks a value, if you
want to optimize for performance then you are responsible for caching type
checker that is returned.
An example recursive type checker:
local entity_type = types.shape {
name = types.string,
child = types.nil + types.proxy(function() return entity_type end)
}
A proxy is needed above because the value of entity_type
is nil
while the
type checker is being constructed. By using the proxy, we can create a closure
to the variable that will eventually hold the entity_type
checker.
Built in types can be used directly without being constructed.
types.string
- checks fortype(val) == "string"
types.number
- checks fortype(val) == "number"
types.func
- checks fortype(val) == "function"
types.boolean
- checks fortype(val) == "boolean"
types.userdata
- checks fortype(val) == "userdata"
types.table
- checks fortype(val) == "table"
types.nil
- checks fortype(val) == "nil"
types.array
- checks for table of numerically increasing indexestypes.integer
- checks for a number with no decimal component
Additionally there's the special any type:
types.any
- succeeds no matter what the type
Every type checker has the follow methods:
Calling check_value
is equivalent to calling the type checker object like a
function. The __call
metamethod is provided on all type checker objects to
allow you easily test a value by treating them like a function.
Tests value
against the type checker. Returns true
(or the current state
object) if the value passes the check. Returns nil
and an error message as a
string if there is a mismatch. The error message will identify where the
mismatch happened as best it can.
check_value
will abort on the first error found, and only that error message is returned.
Will make a deep copy of the value, checking the type and performing any
transformations if necessary. You can use the transform operator (/
) to specify
how values are transformed.
local t = types.number + types.string / tonumber
t:transform(10) --> 10
t:transform("15") --> 15
If any tags are used, a tabled of tagged values is returned as the second argument.
This method is deprecated, use the
type:transform
instead
An alias for type:transform(value)
Returns a new type checker that matches the same type, or nil
.
Returns a wrapped type checker that will use description
to describe the type
when an error message is returned. description
can either be a string
literal, or a function. When using a function, it must return the description
of the type as a string.
Causes the type checker to save matched values into the state object. If
name_or_fn
is a string, then the tested value is stored into the state with
key name_or_fn
.
If name_or_fn
is a function, then you provide a callback to control how the
state is updated. The function takes as arguments the state object and the
value that matched:
-- an example tag function that accumulates an array
types.number:tag(function(state, value)
-- nested objects should be treated as read only, so modifications are done to a copy
if state.numbers
state.numbers = { unpack state.numbers }
else
state.numbers = { }
end
table.insert(state.numbers, value)
end)
This is illustrative example. If you need to accumulate a list of values then use the
[]
syntax for tag names.
You can mutate the state
argument with any changes. The return value of this
function is ignored.
Note that state objects are generally immutable. Whenever a state modifying operation takes place, the modification is done to a copy of the state object. This is to prevent changes to the state object from being kept around when a failing type is tested.
A function
tag gets a copy of the current state as its first argument ready
for editing. The copy is a shallow copy. If you have any nested objects then
it's necessary to clone them before making any modifications, as seen in the
example above.
Pushes a new state object on top of the stack. After the scoped type matches,
the state it created is assigned to the previous scope with the key name
.
It is equivalent to using the types.scope
constructor like so:
-- The following two lines are equivalent
type:scope(name) --> scoped type
types.scope(type, { tag = name }) --> scoped type
This method is deprecated, use the
open = true
constructor option on shapes instead
This method is only available on a type checker generated by types.shape
.
Returns a new shape type checker that won't fail if there are extra fields not specified.
An alias for the transform pattern:
type + types.any / func * type
In English, this will let a value that matches type
pass through, otherwise
for anything else call func(value)
and let the return value pass through if
it matches type
, otherwise fail.
Oct 19 2019 - 2.1.0
- Add
types.partial
alias for open shape - Add
types.array_contains
- Add
not
type, and unary minus operator - Add MoonScript module:
class_type
,instance_type
,instance_type
checkers
Aug 09 2018 - 2.0.0
- Add overloaded operators to compose types
- Add transformation interface
- Add support for tagging
- Add
state
parameter that's passed through type checks - Replace repair interface with simple transform
- Error messages will never re-output the value
- Type objects have a new interface to describe their shape
Feb 10 2016 - 1.2.1
- Fix bug where literal fields with no dot operator could not be checked
- Better failure message when field doesn't match literal value
- Add
types.nil
Feb 04 2016 - 1.2.0
- Add the repair interface
Jan 25 2016 - 1.1.0
- Add
types.map_of
- Add
types.any
Jan 24 2016
- Initial release
Copyright (C) 2018 by Leaf Corcoran
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.