@quentystudios/t
v3.0.0
Published
A Runtime Type Checker for Roblox (forked to provide Lua version)
Downloads
304
Readme
t is a module which allows you to create type definitions to check values against.
Download
You can download the latest copy of t here.
Why?
When building large systems, it can often be difficult to find type mismatch bugs.
Typechecking helps you ensure that your functions are recieving the appropriate types for their arguments.
In Roblox specifically, it is important to type check your Remote objects to ensure that exploiters aren't sending you bad data which can cause your server to error (and potentially crash!).
Crash Course
local t = require(path.to.t)
local fooCheck = t.tuple(t.string, t.number, t.optional(t.string))
local function foo(a, b, c)
assert(fooCheck(a, b, c))
-- you can now assume:
-- a is a string
-- b is a number
-- c is either a string or nil
end
foo() --> Error: Bad tuple index #1: string expected, got nil
foo("1", 2)
foo("1", 2, "3")
foo("1", 2, 3) --> Error: Bad tuple index #3: (optional) string expected, got number
Check out src/t.spec.lua for a variety of good examples!
Primitives
|Type | |Member | |---------|--|-----------| |boolean |=>|t.boolean | |thread |=>|t.thread | |function |=>|t.callback | |nil |=>|t.none | |number |=>|t.number | |string |=>|t.string | |table |=>|t.table | |userdata |=>|t.userdata |
Any primitive can be checked with a built-in primitive function.
Primitives are found under the same name as their type name except for two:
- nil -> t.none
- function -> t.callback
These two are renamed due to Lua restrictions on reserved words.
All Roblox primitives are also available and can be found under their respective type names.
We won't list them here to due how many there are, but as an example you can access a few like this:
t.Instance
t.CFrame
t.Color3
t.Vector3
-- etc...
You can check values against these primitives like this:
local x = 1
print(t.number(x)) --> true
print(t.string(x)) --> false, "string expected, got number"
Type Composition
Often, you can combine types to create a composition of types.
For example:
local mightBeAString = t.optional(t.string)
print(mightBeAString("Hello")) --> true
print(mightBeAString()) --> true
print(mightBeAString(1)) --> false, "(optional) string expected, got number"
These get denoted as function calls below with specified arguments. check
can be any other type checker.
Meta Type Functions
The real power of t is in the meta type functions.
t.any
Passes if value is non-nil.
t.literal(...)
Passes if value matches any given value exactly.
t.keyOf(keyTable)
Returns a t.union of each key in the table as a t.literal
t.valueOf(valueTable)
Returns a t.union of each value in the table as a t.literal
t.optional(check)
Passes if value is either nil or passes check
t.tuple(...)
You can define a tuple type with t.tuple(...)
.
The arguments should be a list of type checkers.
t.union(...)
- ( alias: t.some(...)
)
You can define a union type with t.union(...)
.
The arguments should be a list of type checkers.
At least one check must pass
i.e. t.union(a, b, c)
-> a OR b OR c
t.intersection(...)
- ( alias: t.every(...)
)
You can define an intersection type with t.intersection(...)
.
The arguments should be a list of type checkers.
All checks must pass
i.e. t.intersection(a, b, c)
-> a AND b AND c
t.keys(check)
Matches a table's keys against check
t.values(check)
Matches a table's values against check
t.map(keyCheck, valueCheck)
Checks all of a table's keys against keyCheck
and all of a table's values against valueCheck
There's also type checks for arrays and interfaces but we'll cover those in their own sections!
Special Number Functions
t includes a few special functions for checking numbers, these can be useful to ensure the given value is within a certain range.
General:t.nan
determines if value is NaN
All of the following checks will not pass for NaN
values.
If you need to allow for NaN
, use t.union(t.number, t.nan)
t.integer
checks t.number
and determines if value is an integer
t.numberPositive
checks t.number
and determines if the value > 0
t.numberNegative
checks t.number
and determines if the value < 0
Inclusive Comparisons:t.numberMin(min)
checks t.number
and determines if value >= min
t.numberMax(max)
checks t.number
and determines if value <= max
t.numberConstrained(min, max)
checks t.number
and determines if min <= value <= max
Exclusive Comparisons:t.numberMinExclusive(min)
checks t.number
and determines if value > min
t.numberMaxExclusive(max)
checks t.number
and determines if value < max
t.numberConstrainedExclusive(min, max)
checks t.number
and determines if min < value < max
Special String Functions
t includes a few special functions for checking strings
t.match(pattern)
checks t.string
and determines if value matches the pattern via string.match(value, pattern)
Arrays
In Lua, arrays are a special type of table where all the keys are sequential integers.
t has special functions for checking against arrays.
t.array(check)
determines that the value is a table and all of it's keys are sequential integers and ensures all of the values in the table match check
Interfaces
Interfaces can be defined through t.interface(definition)
where definition
is a table of type checkers.
For example:
local IPlayer = t.interface({
Name = t.string,
Score = t.number,
})
local myPlayer = { Name = "TestPlayer", Score = 100 }
print(IPlayer(myPlayer)) --> true
print(IPlayer({})) --> false, "[interface] bad value for Name: string expected, got nil"
You can use t.optional(check)
to make an interface field optional or t.union(...)
if a field can be multiple types.
You can even put interfaces inside interfaces!
local IPlayer = t.interface({
Name = t.string,
Score = t.number,
Inventory = t.interface({
Size = t.number
})
})
local myPlayer = {
Name = "TestPlayer",
Score = 100,
Inventory = {
Size = 20
}
}
print(IPlayer(myPlayer)) --> true
If you want to make sure an value exactly matches a given interface (no extra fields),
you can use t.strictInterface(definition)
where definition
is a table of type checkers.
For example:
local IPlayer = t.strictInterface({
Name = t.string,
Score = t.number,
})
local myPlayer1 = { Name = "TestPlayer", Score = 100 }
local myPlayer2 = { Name = "TestPlayer", Score = 100, A = 1 }
print(IPlayer(myPlayer1)) --> true
print(IPlayer(myPlayer2)) --> false, "[interface] unexpected field 'A'"
Roblox Instances
t includes two functions to check the types of Roblox Instances.
t.instanceOf(className[, childTable])
ensures the value is an Instance and it's ClassName exactly matches className
If you provide a childTable
, it will be automatically passed to t.children()
t.instanceIsA(className)
ensures the value is an Instance and it's ClassName matches className
by a IsA comparison. (see here)
t.children(checkTable)
Takes a table where keys are child names and values are functions to check the children against.
Pass an instance tree into the function.
Warning! If you pass in a tree with more than one child of the same name, this function will always return false
Roblox Enums
t allows type checking for Roblox Enums!
t.Enum
Ensures the value is an Enum, i.e. Enum.Material
.
t.EnumItem
Ensures the value is an EnumItem, i.e. Enum.Material.Plastic
.
but the real power here is:
t.enum(enum)
This will pass if value is an EnumItem which belongs to enum
.
Function Wrapping
Here's a common pattern people use when working with t:
local fooCheck = t.tuple(t.string, t.number, t.optional(t.string))
local function foo(a, b, c)
assert(fooCheck(a, b, c))
-- function now assumes a, b, c are valid
end
t.wrap(callback, argCheck)
t.wrap(callback, argCheck)
allows you to shorten this to the following:
local fooCheck = t.tuple(t.string, t.number, t.optional(t.string))
local foo = t.wrap(function(a, b, c)
-- function now assumes a, b, c are valid
end, fooCheck)
OR
local foo = t.wrap(function(a, b, c)
-- function now assumes a, b, c are valid
end, t.tuple(t.string, t.number, t.optional(t.string)))
Alternatively, there's also:
t.strict(check)
wrap your whole type in t.strict(check)
and it will run an assert
on calls.
The example from above could alternatively look like:
local fooCheck = t.strict(t.tuple(t.string, t.number, t.optional(t.string)))
local function foo(a, b, c)
fooCheck(a, b, c)
-- function now assumes a, b, c are valid
end
Tips and Tricks
You can create your own type checkers with a simple function that returns a boolean.
These custom type checkers fit perfectly with the rest of t's functions.
If you roll your own custom OOP framework, you can easily integrate t with a custom type checker.
For example:
local MyClass = {}
MyClass.__index = MyClass
function MyClass.new()
local self = setmetatable({}, MyClass)
-- setup instance
return self
end
local function instanceOfClass(class)
return function(value)
local tableSuccess, tableErrMsg = t.table(value)
if not tableSuccess then
return false, tableErrMsg or "" -- pass error message for value not being a table
end
local mt = getmetatable(value)
if not mt or mt.__index ~= class then
return false, "bad member of class" -- custom error message
end
return true -- all checks passed
end
end
local instanceOfMyClass = instanceOfClass(MyClass)
local myObject = MyClass.new()
print(instanceOfMyClass(myObject)) --> true
Known Issues
You can put a t.tuple(...)
inside an array or interface, but that doesn't really make any sense..
In the future, this may error.
Notes
This library was heavily inspired by io-ts, a fantastic runtime type validation library for TypeScript.
Why did you name it t?
The whole idea is that most people import modules via:local X = require(path.to.X)
So whatever I name the library will be what people name the variable.
If I made the name of the library longer, the type definitions become more noisy / less readable.
Things like this are pretty common:local fooCheck = t.tuple(t.string, t.number, t.optional(t.string))