Hello! Today, I’m here to teach you the basics on how to use the New Type Solver and its new features to advance your coding to the next level. Especially with the new built-in and custom type-function features.
What are Type Functions?
Type functions are functions that run during analysis time, and they operate on types instead of runtime values. Basically, they are functions that can transform types and even return new types, depending on what you want to accomplish. Type functions allow you to do achieve things that were not possible to do in the old-solver, such as inheritance, overloading, and more.
Built-in type functions
There are certain built-in type functions that you can already use to manipulate types, such as, keyof, rawkeyof
.
keyof<T>
This type function returns an union
type out of the keys of the given table type.
For example:
local animals = {
cat = { speak = function() print "meow" end },
dog = { speak = function() print "woof woof" end },
monkey = { speak = function() print "oo oo" end },
fox = { speak = function() print "gekk gekk" end }
}
type AnimalType = keyof<typeof(animals)> -- "cat" | "dog" | "monkey" | "fox"
rawkeyof<T>
Works the same as keyof
, except it ignores metatables.
For example:
local tbl = setmetatable({test = ""}, {__index = {test_2 = ""}})
type a = keyof<typeof(tbl)> -- "test" | "test_2"
type b = rawkeyof<typeof(tbl)> -- "test"
index<T, U>
This function returns an union
type out of the values of the given table type. Unlike keyof
and rawkeyof
, this function requires 2 arguments: A table and a key type.
For example:
type Person = {
age: number,
name: string,
alive: boolean
}
type idxType = index<Person, keyof<Person>> -- number | string | boolean
type idxType2 = index<Person, "age" | "name"> -- number | string
Here, we gave the first argument as the Person
table type to the index
function. The first argument is the table that we are going to look up the types of values from. The second argument is the key type(s) that the lookup will be made with. This returns the type(s) of values found as an union
.
rawget<T, U>
Works the same as index
, except it ignores metatables.
setmetatable<T, U>
This type function works similarly to the main-runtime version of itself, except in the type-runtime, it creates a metatable
type.
local clock = {}
type Identity = typeof(setmetatable({} :: { time: number }, { __index = clock }))
is now reduced to:
local clock = {}
type Identity = setmetatable<{ time: number }, { __index: typeof(clock) }> -- evaluates to { time: number, @metatable: { __index: typeof(clock) } }
getmetatable<T>
Like setmetatable
, it works similarly to the main-runtime version of itself, except in the type-runtime, it returns the metatable
type of the given type.
local animal = {}
type Identity = setmetatable<{ sound: string }, { __index: typeof(animal) }>
type ReversedIdentity = getmetatable<Identity> -- evaluates to { __index: typeof(animal) }
Custom Type Functions (a.k.a User-Defined Type Functions)
So far, the built-in type functions already allow us to expand the capabilities of types, but we can take it to a whole new step.
We can create our own type functions that suit our needs, therefore we can use luau code itself to evaluate the logic behind how types are determined.
Here’s a basic type function example:
type function test()
return types.string
end
type testType = test<> -- string
Here, we created a basic type function called test
that returns a string
type.
Just like the built-in type functions, we call custom type functions with <
and >
brackets.
Note: All type functions must return a type when called from outside of type functions. Otherwise, a type-error will be emitted.
(In addition to the types library, which is explained below, type functions also have access to these libraries and functions.)
The types
library
In the previous example, to return a type, we used types
, which is a global library which contains properties that reference built-in types, and functions that allows you to create your own types.
This library can only be used within type-functions, though it is very powerful.
The types library properties:
types.any
types.unknown
types.never
types.boolean
types.buffer
types.number
types.string
types.thread
(More info about the properties can be found here: Type checking - Luau)
Here’s an example type-function that replicates the built-in function keyof
:
type function simple_keyof(ty: type)
-- Ignoring unions or intersections of tables for simplicity.
if not ty:is("table") then -- :is() is a method of the type object (given as the argument) similar to :isA of an Instance.
error("Can only call keyof on tables.")
end
local union = nil
for property in ty:properties() do -- :properties() is a method of a table type instance that allows you to retrieve properties of a table type.
union = if union then types.unionof(union, property) else property -- .unionof is a function of the types library that creates an union type.
end
return if union then union else types.singleton(nil) -- .singleton is a function that creates a string literal (a.k.a singleton), such as "test".
end
type person = {
name: string,
age: number,
}
--- keys = "age" | "name"
type keys = simple_keyof<person>
Here, we replicated the functionality of the keyof
built-in type function with our own custom type function. Our function takes in a table
type, which gets represented as a type
instance in the type-runtime.
The type
instance
Types are represented as type instances in the type-runtime. This way, you can use them with luau code that allows you to change them or even create your own.
type
instances can have extra properties and methods described depending on its tag.
type.tag: "nil" | "unknown" | "never" | "any" | "boolean" | "number" | "string" | "singleton" | "negation" | "union" | "intersection" | "table" | "function" | "class" | "thread" | "buffer"
To see all methods that a type instance can have, you can visit this page: Type checking - Luau. In the next examples, I will mention some of the most popular ones.
Singleton type
instance
For those who don’t know, singletons are types that represents one single value at runtime.
Strings and booleans are representable as singleton types, however, we generally use strings to represent singletons.
To create a singleton, you use the types.singleton()
function.
types.singleton(arg: string | boolean | nil): type
Table type
instance
Table type
instances represent table types in the type runtime, and have certain special methods allowing you to transform them to whatever you wish.
To create a table type
instance, you use the types.newtable()
function.
Read and Write types
Table properties have separate types for reading from and writing to them. We call these types read
and write
types. The read
type allows you to read from the property, whereas the write
type allows you to write to the property. If a property has both of these types, it can both be read from and written into.
Read-only properties
Suppose you have a property called name
, and you wish to indicate that this property can only be read from, and not be written on to. Using the new type-solver, you can specifically define this property to be read-only, using a special keyword called read
. This operation removes the write
type from the property, which makes it read-only.
type Person = {
read name: string,
age: number
}
local a: Person = {name = "", age = 18}
a.name = "" -- Type Error: Property name of the table 'Person' is read-only.
Write-only properties
The idea is the same as read-only properties, except this time we want to make a property write-only, this means it cannot be read from. Using the keyword write
, you can make a property write-only. This operation removes the read
type from the property, which makes it write-only.
type Person = {
write name: string,
age: number
}
local a: Person = {name = "", age = 18}
print(a.name) -- Type Error: Property name of table '{...}' is write-only.
setreadproperty(key: type, value: type?)
Sets the type for reading from the property named by key
, leaving the type for writing this property as-is. The key
parameter must be a singleton string type
.
-
value
will be set as theread type
, thewrite type
will be unchanged. - If
key
is not already present, only aread type
will be set, making the property read-only. - If
value
isnil
, the property is removed.
setwriteproperty(key: type, value: type?)
Sets the type for writing to the property named by key
, leaving the type for reading this property as-is. The key
parameter must be a singleton string type
.
-
value
will be set as thewrite type
, theread type
will be unchanged. - If
key
is not already present, only awrite type
will be set, making the property write-only. - If
value
isnil
, the property is removed.
setproperty(key: type, value: type?)
Sets key-value pair in the table’s properties, with the same type for reading from and writing to the table. The key
parameter must be a singleton string type
.
type function test()
local newTable = types.newtable()
newTable:setproperty(types.singleton("hi"), types.any)
return newTable
end
type testType = test<> -- {hi: any}
In this example above, we created a new table type
instance, using the types.newtable()
function. This function creates a new type
instance with the “table” tag
, that comes with special methods that you can use. For example, :setproperty()
being one of them, allows you to set the types of the properties within the table type. Using this, we set the key to the singleton string type "hi"
using the types.singleton()
function, and the value type of that key to any
using the any
property of the types
library.
readproperty(key: type): type?
Returns the type used for reading values from this property, or nil
if the property doesn’t exist.
type function customindex(tbl: type, key: type)
if not tbl:is("table") then error("The first given type isn't a table!") end
if not key:is("singleton") then error("The second given type isn't a singleton!") end
local property = tbl:readproperty(key)
if not property then error("Given property does not exist on the given table type.") end
return property
end
type Person = {
name: string,
age: number
}
type indexResult = customindex<Person, "name"> -- string
In the example above, we created a basic custom index
function (that does not create an union
type) that returns the type
of the found property, if it exists within the table type
.
We first make sure to validate the types of the given types, tbl
must be a table type
, and key
must be a singleton type
. And then we use the :readproperty()
method of the table type
with the given singleton type
to get the property, and return it.
This function only works on properties that have a read
type. So, only properties that aren’t write-only will be returned.
writeproperty(key: type): type?
Returns the type used for writing values to this property, or nil
if the property doesn’t exist.
This function works the same as to readproperty()
, except it only returns properties that aren’t read-only, because it only works on properties that have a write
type.
setindexer(index: type, result: type)
Sets the table’s indexer, using the same type for reads and writes.
Indexers are the default types that return the result type of an index operation on a table.
type function createTable()
local newTable = types.newtable()
newTable:setindexer(types.number, types.string)
return newTable
end
type tableType = createTable<> -- {[number]: string}
In the example above, we created a new table type and set its indexer to {[number]: string}
, which means when we index this table with a number, it will return a value of a string type.
(More methods of the table type instance can be found here: Type checking - Luau)
Function type
instance
The function type
instance represents function types in the type-runtime.
setparameters(head: {type}?, tail: type?)
Sets the function’s parameters, with the ordered parameters in head
and the variadic tail in tail
.
type function createFunction()
local newFunction = types.newfunction()
newFunction:setparameters({
types.string,
types.number
})
return newFunction
end
type functionType = createFunction<> -- (string, number) -> ()
In the example above, we created a new function type instance using the types.newfunction()
function, and set its parameters by using :setparameters()
. This function takes a head
table, that has type
s that determine the types of the parameters of the function. Unfortunately, you cannot set the names of the parameters currently, and can only set the types in order.
You can also use the tail
parameter to create a variadic parameter for the function type:
type function createFunction()
local newFunction = types.newfunction()
newFunction:setparameters({
types.string,
types.number
}, types.boolean)
return newFunction
end
type functionType = createFunction<> -- (string, number, ...boolean) -> ()
setreturns(head: {type}?, tail: type?)
Sets the function’s return types, with the ordered parameters in head
and the variadic tail in tail
.
type function createFunction()
local newFunction = types.newfunction()
newFunction:setparameters({
types.string,
types.number
}, types.boolean)
newFunction:setreturns({
types.boolean,
types.any
})
return newFunction
end
type functionType = createFunction<> -- (string, number, ...boolean) -> (boolean, any)
In the example above, we’ve added the return types of the function, using the :setreturns()
function. Like :setparameters()
, it takes a head
table, and can also take a type in its tail
parameter, to create a variadic return type.
setgenerics(generics: {type}?)
Sets the function’s generic type
s.
type function pass()
local T = types.generic("T") -- T
local Us, Vs = types.generic("U", true), types.generic("V", true) -- U..., V...
local f = types.newfunction()
f:setparameters({T}, Us); -- (T, U...)
f:setreturns({T}, Vs); -- (T, V...)
f:setgenerics({T, Us, Vs}); -- <T, U..., V...>
return f
end
type X = pass<> -- <T, U..., V...>(T, U...) -> (T, V...)
In the example above, we created a new function type that contains generic types, and type packs.
To create a generic function type
, we first create a generic type
, by using the types.generic(name: string?, ispack: boolean?)
function. This function can either create a generic type, or a generic type pack.
Using this function, we first created a generic called T
, then we created 2 generic type packs called: U
and V
. Then we created a new function, and set its parameters to the T
generic and the U
generic type pack, and then set its returns to the T
generic and the V
generic type pack.
Finally, using the :setgenerics()
method, we set the function’s generics to the T
generic, and the U
and V
generic type packs, giving us the final function type that is set to the X
type alias.
(More methods of the function type instance can be found here: Type checking - Luau)
Class type
instance
This is a special type that cannot be created manually, as it represents classes that are defined outside of the Luau runtime. Instance
for example, is a Roblox Engine Class that does not exist within the Luau runtime itself. These classes can only be defined in Luau definition files that only runtime embedders can use. But we can still operate on them.
properties(): { [type]: { read: type?, write: type? } }
Returns the properties of the class with their respective read
and write
types.
metatable(): type?
Returns the class’ metatable, or nil
if it doesn’t exist.
classtype:indexer(): { index: type, readresult: type, writeresult: type }?
Returns the class’ indexer, or nil
if it doesn’t exist.
(More methods of the class type instance can be found here: Type checking - Luau)
For more information about the types of the type
instance, you can visit here: Type checking - Luau
Detailed Concepts
In this section, I’ll be talking about some detailed concepts, such as negations, unions, optional types, and intersections.
Negation Types
Negation types are types that represents the complement of the type being negated, which is a union type of all inhabited types (unknown
) excluding the type being negated.
For example:
local b = ""
if typeof(b) == "string" and b ~= "a" then
-- b: string & ~"a"
end
In this example, we have a variable called b
, which is a string
.
We then evaluate the type of the b
, and check if it’s not "a"
.
If this succeeds, the type of b
gets refined to be string & ~"a"
, which is a co-finite string set that accepts any string
excluding "a"
.
You can also create a negation type manually with custom type functions, using the types.negationof()
function.
type function returnNegation(ty: type)
return types.negationof(ty)
end
type negation = string & returnNegation<"a"> -- string & ~"a"
Union Types
A union type represents one of the types in a set. If you try to pass a union onto another thing that expects a more specific type, it will fail.
For example:
local stringOrNumber: string | number = "foo"
local onlyString: string = stringOrNumber -- will fail
local onlyNumber: number = stringOrNumber -- will fail
In the example above, we have a variable called stringOrNumber
, which is an union type that has string
and number
. This means this variable can either be a string
, or a number
. And because this is a set, if you try to pass this type onto something that is more specific, in this example onlyString
or onlyNumber
, it will fail.
You can also create a union type manually with custom type functions, using the types.unionof()
function.
type function returnUnion(ty: type)
return types.unionof(ty, types.string)
end
type union = returnUnion<number> -- number | string
Tip: Since optional types are technically an union of a type and the nil
type, you can create an optional type by doing this:
type function returnOptional(type)
return types.unionof(type, types.singleton(nil))
end
type optional = returnOptional<string> -- string?
Or using the types.optional()
function that provides a shorthand for the function above:
type function returnOptional(type)
return types.optional(type)
end
type optional = returnOptional<string> -- string?
Intersection Types
An intersection type represents all of the types in a set. It’s useful for two main things: to join multiple tables together, or to specify overloadable functions.
type XCoord = {x: number}
type YCoord = {y: number}
type ZCoord = {z: number}
type Vector2 = XCoord & YCoord
type Vector3 = XCoord & YCoord & ZCoord
local vec2: Vector2 = {x = 1, y = 2} -- ok
local vec3: Vector3 = {x = 1, y = 2, z = 3} -- ok
type SimpleOverloadedFunction = ((string) -> number) & ((number) -> string)
local f: SimpleOverloadedFunction
local r1: number = f("foo") -- ok
local r2: number = f(12345) -- not ok
local r3: string = f("foo") -- not ok
local r4: string = f(12345) -- ok
Note: it’s impossible to create an intersection type of some primitive types, e.g.
string & number
, or string & boolean
, or other variations thereof.
Note: Luau still does not support creating overloadable functions directly. So when declaring
SimpleOverloadedFunction
as the type of f
, and trying to create a function using that f
variable will fail.
local f: SimpleOverloadedFunction = function() -- will fail, parameter and return types cannot be declared.
end
The only way to go around this issue, is by casting the f
function as SimpleOverloadedFunction
after it’s been written.
local function f(argument: string | number): string | number?
if typeof(argument) == "string" then
return 1
elseif typeof(argument) == "number" then
return ""
end
return nil
end
local f = f :: SimpleOverloadedFunction
f(1) -- valid
f("") -- valid
You can also create an intersection type manually with custom type functions, using the types.intersectionof()
function.
type function returnIntersection(type)
return types.intersectionof(type, types.number)
end
type intersection = returnIntersection<string> -- string & number
For more info about other concepts and features, you can visit here: Type checking - Luau
General Examples and Places where you can use Custom Type Functions
Custom and Built-in type functions can be used within many places.
TypeForge
One popular and well-known example is TypeForge. It is a module that contains many utility and other type functions that may speed-up your type-checking process.
Here’s an example from it:
type function _tableSetProperty(input: { [any]: any }, key: any, read: any, write: any)
input:setreadproperty(key, read)
-- The `write == read` comparison is to ensure that the property is not
-- unnecessarily separated into 2 different read write properties.
if write then input:setwriteproperty(key, if (read and write) and Equals(write, read) then read else write) end
end
export type function TableDiff(inputA: { [any]: any }, inputB: { [any]: any })
local output = types.newtable()
local inputAProps, inputBProps = inputA:properties(), inputB:properties()
for key, value in inputAProps do
local inputARead, inputAWrite = value.read, value.write
local inputBRead, inputBWrite = inputB:readproperty(key), inputB:writeproperty(key)
local maybeRead = if inputARead and not inputBRead then inputARead else nil
local maybeWrite = if inputAWrite and not inputBWrite then inputAWrite else nil
_tableSetProperty(output, key, maybeRead, maybeWrite)
end
for key, value in inputBProps do
local inputBRead, inputBWrite = value.read, value.write
local inputARead, inputAWrite = inputA:readproperty(key), inputA:writeproperty(key)
local maybeRead = if inputBRead and not inputARead then inputBRead else nil
local maybeWrite = if inputBWrite and not inputAWrite then inputBWrite else nil
_tableSetProperty(output, key, maybeRead, maybeWrite)
end
return output
end
This module offers many more utility functions related to almost all types present in the types
library. Be sure to check it out.
Class++
This is my own module that I have created to introduce an advanced class system to Luau. Unfortunately, in the old-solver, many of the things I mentioned here were not possible, but with the new-solver and custom type functions, many things such as inheritance, operator overloading, and dynamic type creation is now possible.
(From the Type API)
export type function getConstructorType(classData: type, objectData: type)
local properties = classData:properties()
local constructorParams
for k, v in properties do
local key = k:value()
local value = v.read
if key == "constructor" and value then
constructorParams = value:parameters()
end
end
local newHead: {type} = {}
local allowedTags = {"string", "any", "singleton", "boolean", "number", "class", "buffer", "nil", "table", "function", "union"}
if constructorParams and constructorParams.head then
for i, type in constructorParams.head do
if i == 1 then continue end
if not table.find(allowedTags, type.tag) then
table.insert(newHead, types.any)
else
table.insert(newHead, type)
end
end
end
return types.newfunction({head = newHead}, {head = {objectData}})
end
This is the type function that creates the type for the class.new
function, to match the types of the given constructor
function in the classData
table. This allows for a smoother user experience, where now, the user does not need to check the constructor
function’s parameter types in order to know what to pass to the class.new
function.
You can learn more by visiting the Type API’s source code through this link, it has many more functions that allows Class++ to be more capable than ever before.
What is next?
Coming Soon