metaty: typosafe types using Lua metatables
Metatype is a library and specification for creating performant, documented,
and typo-safe Lua record-types which can be formatted.
The #metaty.freeze module allows making types immutable (after calling
freeze on them) and making tables immutable.
Turn off typo checking by setting the global LUA_OPT=3 or higher.
Below is a simple example using
metaty.mod to create a module and calling
metatype directly to create a record (the same as
metaty.record).
local mty = require'metaty'
--- module documentation
local M = mty.mod'myMod'
--- Documentation for Pos (position)
M.Pos = mty'Pos' {
-- fields are a string of form "name[type]: documentation",
'x[int]: x coordinate',
'y[int]: y coordinate', y = 0,
}
local p1 = Pos{x=4}
local p1 = Pos{x=4, y=3, z=5} -- ERROR: does not have field z
Metaty also supports creating typosafe enum types with
metaty.enum. An enum is typosafe both when creating the variant
(i.e.
v = MyEnum.VARIANT) and when matching using the
matcher method
below. It also allows using checked enums in
pod.html.
One of the main benefits of using an enum is to ensure that when you are
matching you don't make a typo mistake (i.e. WOKER instead of WORKER). In
lua there is no native switch statement (or similar), but table lookup
on functions can be equally as good -- see the example below.
M.Job = enum'Job' {
OWNER = 1,
MANAGER = 2,
COOK = 3,
WAITER = 4,
}
assert('OWNER', M.Job.OWNER)
-- string or id input returns string
assert('OWNER', M.Job.name(1))
assert('OWNER', M.Job.name('OWNER'))
-- string or id input returns id
assert(1, M.Job.id(1))
assert(1, M.Job.id('OWNER'))
-- create a table that converts a variant (name or id) -> function.
local doJob = M.Job:matcher {
OWNER = function() print'tell them to get to work' end,
MANAGER = function() print'get to work!' end,
COOK = function() print'order up!' end,
WAITER = function() print'they want spam and eggs' end,
-- Removing any of the above will cause an error that not all variants
-- are covered. You can use :partialMatcher if you want to
-- return nil instead.
--
-- Below will cause an error: no variant DISHWASHER
DISHWASHER = function() end
}
-- call in your own function like:
doJob[job](my, args)
The example in
#record expands to the following. Note that the "typosafe"
elements are removed when
LUA_OPT > 3.
local M = {}
local metaty = require'metaty'
local Pos = setmetatable({
__name='Pos',
y = 0,
-- used with metaty.Fmt and help()
__fields={'x', 'y', x='[int]', y='[int]'},
__newindex = metaty.newindex, -- typosafe setting
}, {
__call = function(T, t)
metaty.fieldsCheck(T.__fields, t) -- typosafe constructor
return setmetatable(t, T)
end,
__index = metaty.index, -- typosafe getting
})
Pos.__index = Pos
-- `mod` gives documentation reflection
PKG_LOCS[M.myFn] = 'path/to/file.lua:123'
PKG_NAMES[M.myFn] = 'mymod.Pos'
PKG_LOOKUP['myMod.Pos'] = M.Pos
- ty(v) return the metaty of v. For tables this is getmetatable(v),
else it is type(v).
- metaty'name' {'field1[type] documentation', 'field2[type]'}
creates a documented and typo-safe record type (see examples)
Lua is a fast and fun language. However it often lacks the ability to express
intent when it comes to the structure of data. Also, not only is it not
type-safe but it is also TYPO-unsafe -- small mistakes in the name of a field
can easily result in hard to diagnose bugs, even when they occur in one's
unit-test suite.
Checking for typos incurrs a small performance cost, so it is disabled by
default. However, it is well-worth the cost in your unit tests.
For a type to be considered a "metaty" the only requirement is that it has a
metatable set and that metatable has a
__name field.
The following fields can optionally be set on the metatable:
- __fmt: used with #Package_fmt
- __fields: should contain a table of fieldName -> fieldtype.
fieldType can be an arbitrary string and is only for documentation, though
future libraries/applications (type checkers) may eventually wish to consume
it. metaty (the library) uses the format "fieldName [user-specified-type]"
- This is used by formatting libraries when printing the types (so the fields
are printed in deterministic order).
- default values (i.e. y in the example) are assigned directly to the type.
Documentation formatters may use these to format help messages.
- __fieldIds: set using the @<decimal> syntax after the type, used
for de/serializing.
In addition, there is runtime type specification defined below.
Note: Runtime typo checking has a cost and can be disabled with
LUA_OPT=3 or higher.
You can override the typo-checking behavior of a single type with
getmetatable(MyType).__call = myConstructor
getmetatable(MyType).__index = myIndex
MyType.__newindex = myNewIndex
LUA_OPT is an integer from 0-3 which is intended to have the following
semantic meanings:
- 4: may be used for more optimization of certain library functions with
reduced dynamic abilities, i.e. logging.
- 3: optimize out most safety checks and documentation.
- 2: typosafety or related only, turn off below.
- 1 (default): typosafety and related, plus include docs.
- 0: no optimizations, turn on anything. May be used as a debug mode or
similar.
metaty: simple but effective Lua type system using metatable
Functions
- fn isMod(t) -> boolean
Return whether the value is a module.
- fn setup()
Setup lua's global environment to meet metaty protocol.
Currently this only makes non-G global access typosafe (throw error if
not previously set).
- fn isConcrete(v)
Return whether v is a nil, boolean, number or string.
- fn isBuiltin(obj)
Return whether v is concrete or a table with no metatable.
- fn isEnv(var)
Given a string return whether os.getenv returns 'true' or '1'.
- fn isEnvG(var)
Same as isEnv but first checks globals.
- fn getmethod(t, method)
Get method of table if it exists.
This first looks for the item directly on the table, then the item in the
table's metatable. It does NOT use the table's normal __index.
- fn ty(v) -> type
Get the type of the value which is either the values metatable or the
string result of type(v).
- fn tyName(T, default) -> string
Given a type return it's name.
- fn name(o)
Given an object (function, table, userdata) return its name.
Return it's string type if it's not one of the above types.
- fn callable(obj) -> bool
Return whether obj is callable (function or has mt.__call).
- fn validKey(s) -> boolean
Return whether s can be used directly as a key
in syntax.
- fn fninfo(fn) -> name, loc
Extract name,loc from function value.
- fn anyinfo(v) -> name, loc
Extract name,loc from any value (typically mod/type/function).
- fn rawsplit(subj, ctx) -> (state, splitstr)
You probably want split instead.
Usage: for ctx, line in rawsplit, text, {'\n', 1} do
- fn split(subj, pat--[[%s+]], index--[[1]]) -> (cxt, str) iter
split the subj by pattern. ctx has two keys: si (start index) and
ei (end index)
for ctx, line in split(text, '\n') do -- split lines
... do something with line
end
- fn copyMethod(self)
The __copy method.
- fn fmt(self, f)
The default __fmt method.
- fn tostring(self)
The default __tostring method.
- fn doc(R, d)
The default __doc method.
d is of type doc.Documenter.
Tests are in cmd/doc/test.lua
- fn eq(a, b) -> bool
Compare two values (any values) for equality. This will
recursively check tables for equality.
- fn indexError(R, k, lvl)
Throws a formatted index error when called.
- fn index(R, k)
Usage: getmetable(MyType).__index = mty.index
Allows integers to be insert into your value. This is the default __index.
- fn hardIndex(R, k)
Usage: getmetable(MyType).__index = mty.index
Allows nothing except your type's fields to be insert into
your value.
- fn newindex(r, k, v)
Usage: MyType.__newindex = mty.newindex
(record default) this allows non-string keys to always be insert.
- fn hardNewindex(r, k, v)
Usage: MyType.__newindex = mty.hardNewindex
This allows only registered fields to be insert.
- fn construct(T, t)
Usage: getmetatable(MyType).__call = mty.constructChecked
Check type with field typochecking. This is the default when
LUA_OPT <= 2
- fn constructUnchecked(T, t)
Usage: getmetatable(MyType).__call = mty.constructUnchecked
Check type with field typochecking. This is the default when
LUA_OPT >= 3.
- fn construct(T, t)
Usage: getmetatable(MyType).__call = mty.constructChecked
Check type with field typochecking. This is the default when
LUA_OPT <= 2
- fn record(name)
Usage: record'name'{ 'field [type]: documentation' }
Start a new record. See module documentation for details.
- fn recordMod(name)
Start a new record which acts as a lua module (i.e. the file returns it).
- fn isRecord(t)
Return true if t is a record value.
- fn extend(Type, name, fields)
Usage: mty.extend(MyBase, 'NewName', {...new fields...})
Extend the Type with (optional) new name and (optional) additional fields.
- fn extendMod(T, name, fields)
Extend the type with new name and use as a module.
- fn enum(name)
Usage: mty.enum'name' { A = 1, B = 2, ... }
Create an enum type. See module documentation.
- fn want(mod) -> module?
like require but returns nil if not found.
- fn from(a, b)
Usage: local bar, baz = mty.from'foo bar,baz'
Usage: local sfmt, srep = mty.from(string, 'format,rep')
Shortcut for: local foo = require'foo'
local bar, baz = foo.bar, foo.baz
Usage:
freeze.freezy(MyType) to make MyType freezable, see
#metaty.freeze.freezy.
Call usage: freeze(v)
When called: freezes the value, see #metaty.freeze.freeze.
Types: frozen
Functions
- fn frozenNext(FROZEN[f], k)
- fn freeze(v)
Freeze value, making it immutable.
Implementation: If v is a table, the table's actual values are moved
to a table in freeze.FROZEN, which is where they are retrieved.
__index, etc are frozen to give an immutable "view" into this table.
If v is userdata, :freeze() is called on it.
Concrete lua values are already immutable. Functions are considered
already frozen, it is the author's responsibility to ensure functions don't
mutate state.
- fn freezy(R)
Usage: M.MyType = freeze.freezy(mty'MyType' { ... })
Make the type :freeze()-able, after which it will be immutable.
This has a performance cost both before and after the value is frozen.
Through three cheese trees three free fleas flew.
While these fleas flew, freezy breeze blew.
Freezy breeze made these three trees freeze.
Freezy trees made these trees' cheese freeze.
That's what made these three free fleas sneeze.
- Dr Seuss, "Fox in Socks"
- fn isFrozen(v)
Return whether v is immutable.
- fn forceset(t, k, v)
Force set the value, even on a frozen type. Obviously,
this should be used with caution.
A "plain old table" that has been frozen (made immutable).