Skip to content

Latest commit

 

History

History
368 lines (286 loc) · 10.9 KB

README.md

File metadata and controls

368 lines (286 loc) · 10.9 KB

t

A Runtime Type Checker for Roblox
 

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[, childTable])
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))