Programming With Types in Teal
This tutorial is a slightly modified version of the original Teal tutorial, adapted for Dora SSR. The original tutorial can be found here. And this tutorial assumes you are already familiar with Lua.
Welcome to Teal!
In this tutorial, we will go through the basics so you can get up and running type checking your Lua code, through the use of Teal, a typed dialect of Lua.
Why Types
If you're already convinced about the idea of type checking, you may skip this part. :)
The data in your program has types: Lua is a high-level language, so each piece of data stored in the memory of the Lua virtual machine has a type: number, string, function, boolean, userdata, thread, nil or table.
Your program is basically a series of manipulations of data of various types. The program is correct when it does what it is supposed to do, and that will only happen when data is matched with other data of the correct types, like pieces of a puzzle: you can multiply a number by another number, but not by a boolean; you can call a function, but not a string; and so on.
The variables of a Lua program, however, know nothing about types. You can put any value in any variable at any time, and if you make a mistake and match things incorrectly, the program will crash at runtime, or even worse: it will misbehave... silently.
The variables of Teal do know about types: each variable has an assigned type and will hold on to that type forever. This way, there's a whole class of mistakes that the Teal compiler is able to warn you about before the program even runs.
Of course, it cannot catch every possible mistake in a program, but it should help you with things like typos in table fields, missing arguments and so on. It will also make you be more explicit about what kind of data your program is dealing with: whenever that is not obvious enough, the compiler will ask you about it and have you document it via types. It will also constantly check that this "documentation" is not out of date. Coding with types is like pair programming with the machine.
Your first Teal program
Let's start with a simple example, which declares a type-safe function. Let's
call this example add.tl
:
local function add(a: number, b: number): number
return a + b
end
local s = add(1,2)
print(s)
We can also write modules in Teal which we can load from Lua. Let's create our first module:
local addsub = {}
function addsub.add(a: number, b: number): number
return a + b
end
function addsub.sub(a: number, b: number): number
return a - b
end
return addsub
Types in Teal
Teal is a dialect of Lua. This tutorial will assume you already know Lua, so we'll focus on the things that Teal adds to Lua, and those are primarily type declarations.
Types in Teal are more specific than in Lua, because Lua tables are so general. These are the basic types in Teal:
any
nil
boolean
integer
number
string
thread
(coroutine)
Note: An integer
is a sub-type of number; it is of undefined precision,
deferring to the Lua VM.
You can also declare more types using type constructors. This is the summary list with a few examples of each; we'll discuss them in more detail below:
- arrays -
{number}
,{{number}}
- tuples -
{number, integer, string}
- maps -
{string:boolean}
- functions -
function(number, string): {number}, string
Finally, there are types that must be declared and referred to using names:
- enum
- record
- userdata
- arrayrecord
Here is an example declaration of each. Again, we'll go into more detail below, but this should give you an overview:
-- an enum: a set of accepted strings
local enum State
"open"
"closed"
end
-- a record: a table with a known set of fields
local record Point
x: number
y: number
end
-- a userdata record: a record which is implemented as a userdata
local record File
userdata
status: function(): State
close: function(File): boolean, string
end
-- an arrayrecord: a record which doubles as a record and an array
local record TreeNode<T>
{TreeNode<T>}
item: T
end
Local variables
Variables in Teal have types. So, when you declare a variable with the local
keyword, you need to provide enough information so that the type can be determined.
For this reason, it is not valid in Teal to declare a variable with no type at all
like this:
local x -- Error! What is this?
There are two ways, however, to give a variable a type:
- through declaration
- through initialization
Declaration is done writing a colon and a type. When declaring multiple variables at once, each variable should have its own type:
local s: string
local r, g, b: number, number, number
You don't need to write the type if you are initializing the variable on creation:
local s = "hello"
local r, g, b = 0, 128, 128
local ok = true
If you initialize a variable with nil and don't give it any type, this doesn't give any useful information to work with (you don't want your variable to be always nil throughout the lifetime of your program, right?) so you will have to declare the type explicitly:
local n: number = nil
This is the same as omitting the = nil
, like in plain Lua, but it gives the
information the Teal program needs. Every type in Teal accepts nil as a valid
value, even if, like in Lua, attempting to use it with some operations would
cause a runtime error, so be aware!
Arrays
The simplest structured type in Teal is the array. An array is a Lua table where
all keys are numbers and all values are of the same type. It is in fact a Lua
sequence, and as such it has the same semantics as Lua sequences for things
like the # operator and the use of the table
standard library.
Arrays are described with curly brace notation, and can be denoted via declaration or initialization:
local values: {number}
local names = {"John", "Paul", "George", "Ringo"}
Note that values was initialized to nil. To initialize it with an empty table, you have to do so explicitly:
local prices: {number} = {}
Creating empty tables to fill an array is so common that Teal includes a naive inference logic to support determining the type of empty tables with no declaration. The first array assignment to an empty table, reading the code top-to-bottom, determines its type. So this works:
local lengths = {}
for i, n in ipairs(names) do
table.insert(lengths, #n) -- this makes the lengths table a {number}
end
Note that this works even with library calls. If you make assignments of conflicting types, the tl compiler will tell you in its error message from which point in the program it originally got the idea that the empty table was an array of that incompatible type.
Note also that we didn't need to declare the types of i and n in the above
example: the for statement can infer those from the return type of the
iterator function produced by the ipairs call. Feeding ipairs with a {string}
means that the iteration variables of the ipairs loop will be number and
string. For an example of a custom user-written iterator, see the Functions
section below.
Note that all items of the array are expected to be of the same type. If you
need to deal with heterogeneous arrays, you will have to use the cast operator
as
to force the elements to their desired types. Keep in mind that when you
use as
, Teal will accept whatever type you use, meaning that it can also hide
incorrect usage of data:
local sizes: {number} = {34, 36, 38}
sizes[#sizes + 1] = true as number -- this does not perform a conversion! it will just stop tl from complaining!
local sum = 0
for i = 1, #sizes do
sum = sum + sizes[i] -- will crash at runtime!
end
Tuples
Another common usage of tables in Lua are tuples: tables containing an ordered set of elements of known types assigned to its integer keys.
-- Tuples of type {string, integer} containing names and ages
local p1 = { "Anna", 15 }
local p2 = { "Bob", 37 }
local p3 = { "Chris", 65 }
When indexing into tuples with number constants, their type is correctly inferred, and trying to go out of range will produce an error.
local age_of_p1: number = p1[2] -- no type errors here
local nonsense = p1[3] -- error! index 3 out of range for tuple {1: string, 2:
integer}
When indexing with a number
variable, Teal will do its best by making a
union of all the types in the tuple (following the
restrictions on unions detailed below)
local my_number = math.random(1, 2)
local x = p1[my_number] -- => x is a string | number union
if x is string then
print("Name is " .. x .. "!")
else
print("Age is " .. x)
end
Tuples will additionally help you keep track of accidentally adding more elements than they expect (as long as their length is explicitly annotated and not inferred).
local p4: {string, integer} = { "Delilah", 32, false } -- error! expected maximum length of 2, got 3
One thing to keep in mind when using tuples versus arrays is type inference, and when you should or shouldn't need it. A table will be inferred as an array if all of its elements are the same type, and as a tuple if any of its types aren't the same. So if you want an array of a union type instead of a tuple, explicitly annotate it as such:
local array_of_union: {string | number} = {1, 2, "hello", "hi"}
And if you want a tuple where all elements have the same type, annotate that as well:
local tuple_of_nums: {number, number} = {1, 2}
Maps
Another very common type of table is the map: a table where all keys of one given type, and all values are of another given type, which may or may not be the same as that of the keys. Maps are notated with curly brackets and a colon:
local populations: {string:number}
local complicated: {Object:{string:{Point}}} = {}
local modes = { -- this is {boolean:number}
[false] = 127,
[true] = 230,
}
In case you're wondering, yes, an array is functionally equivalent to a map with keys of type number.
When creating a map with string keys you may want to declare its type explicitly, so it doesn't get mistaken for a record. Records are freely usable as maps with string keys when all its fields are of the same type, so you wouldn't have to annotate the type to get a correct program, but the annotation will help the compiler produce better error messages if any errors occur involving this variable:
local is_vowel: {string:boolean} = {
a = true,
e = true,
i = true,
o = true,
u = true,
}
For now, if you have to deal with heterogeneous maps (that is, Lua tables with a mix of types in their keys or values), you'll have to use casts.
Records
Records are the third major type of table supported in Teal. They represent another super common pattern in Lua code, so much that Lua includes special syntax for it (the dot and colon notations for indexing): tables with a set of string keys known in advance, each of them corresponding to a possibly different value type. Records (named as such in honor of the Algol/Pascal tradition from which Lua gets much of the feel of its syntax) can be used to represent objects, "structs", etc.
To declare a record variable, you need to create a record type first.
The type describes the set of valid fields (keys of type string and their values of
specific types) this record can take. You can declare types using local type
and global types using global type
.
local type Point = record
x: number
y: number
end
Types are constant: you cannot reassign them, and they must be initialized with a type on declaration.
Just like with functions in Lua, which can be declared either with local f = function()
or with local function f()
, there is also a shorthand syntax
available for the declaration of record types:
local record Point
x: number
y: number
end
Tables that match the shape of the record type will be accepted as an initializer of variables declared with the record type:
local p: Point = { x = 100, y = 100 }
This, however, won't work:
local p1 = { x = 100, y = 100 }
local p2: Point = p1 -- Error!
Just because a table has fields with the same names and types, it doesn't mean that it is a Point. A Distance could also be defined as fields x and y, but a distance is not a point.
You can always force a type, though, using the as
operator:
local p2 = p1 as Point -- Ok, I'll trust you...
Note we didn't even have to declare the type of p2. The as
expression resolves
as a Point, so p2 picks up that type.
You can also declare record functions after the record definition using the regular Lua colon or dot syntax, as long as you do it in the same scope block where the record type is defined:
function Point.new(x: number, y: number): Point
local self: Point = setmetatable({}, { __index = Point })
self.x = x or 0
self.y = y or 0
return self
end
function Point:move(dx: number, dy: number)
self.x = self.x + dx
self.y = self.y + dy
end
When using the function, don't worry: if you get the colon or dot mixed up, tl will detect and tell you about it!
If you want to define the function in a later scope (for example, if it is a callback to be defined by users of a module you are creating), you can declare the type of the function field in the record and fill it later from anywhere:
local record Obj
location: Point
draw: function(Obj)
end
A record can also have an array part, making it an "arrayrecord". The following is an arrayrecord. You can use it both as a record, accessing its fields by name, and as an array, accessing its entries by number.
local record Node
{Node}
weight: number
name: string
end
Note the recursive definition in the above example: records of type Node can be organized as a tree using its array part.
Finally, records can contain nested record type definitions. This is useful when exporting a module as a record, so that the types created in the module can be used by the client code which requires the module.
local record http
record Response
status_code: number
end
get: function(string): Response
end
return http
You can then refer to nested types with the normal dot notation, and use it across required modules as well:
local http = require("http")
local x: http.Response = http.get("http://example.com")
print(x.status_code)
You can mark a record field as const
to prevent it from being modified after initialization:
local record Point
const x: number
const y: number
end
local point: Point = {x = 100, y = 200}
point.x = 456 -- Error! Cannot modify a const field
You can use a keyword embed
to include all fields of another record in the current record:
local record Point
x: number
y: number
end
local record Circle
embed Point
radius: number
end
local c: Circle = {x = 100, y = 200, radius = 50}
Generics
Teal supports a simple form of generics that is useful enough for dealing collections and algorithms that operate over abstract data types.
You can use type variables wherever a type is used, and you can declare them in both functions and records. Here's an example of a generic function:
local function keys<K,V>(xs: {K:V}):{K}
local ks = {}
for k, v in pairs(xs) do
table.insert(ks, k)
end
return ks
end
local s = keys({ a = 1, b = 2 }) -- s is {string}
we declare the type variables in angle brackets and use them as types. Generic records are declared and used like this:
local type Tree = record<X>
{Tree<X>}
item: X
end
local t: Tree<number> = {
item = 1,
{ item = 2 },
{ item = 3, { item = 4 } },
}
Metamethods
Lua supports metamethods to provide some advanced features such as operator overloading. Like Lua tables, records support metamethods. To use metamethods in records you need to do two things:
- declare the metamethods in the record type using the
metamethod
word to benefit from static type checking; - and assign the metatable with
setmetatable
as you would normally do in Lua to get the dynamic metatable behavior.
Here is a complete example, showing the metamethod
declarations in the
record
block and the setmetatable
declarations attaching the metatable.
local type Rec = record
x: number
metamethod __call: function(Rec, string, number): string
metamethod __add: function(Rec, Rec): Rec
end
local rec_mt: metatable<Rec>
rec_mt = {
__call = function(self: Rec, s: string, n: number): string
return tostring(self.x * n) .. s
end,
__add = function(a: Rec, b: Rec): Rec
local res: Rec = setmetatable({}, rec_mt)
res.x = a.x + b.x
return res
end,
}
local r: Rec = setmetatable({ x = 10 }, rec_mt)
local s: Rec = setmetatable({ x = 20 }, rec_mt)
r.x = 12
print(r("!!!", 1000)) -- prints 12000!!!
print((r + s).x) -- prints 32
Note that we explicitly declare variables as Rec
when initializing the
declaration with setmetatable
. The Teal standard library definiton of
setmetatable
is function<T>(T, metatable<T>): T
, so declaring the correct
record type in the declaration assigns the record type to the type variable
T
in the return value of the function call, causing it to propagate to the
argument types, matching the correct table and metatable types.
Operator metamethods for integer division //
and bitwise operators are
supported even when Teal runs on top of Lua versions that do not support them
natively, such as Lua 5.1.
Enums
Enums are a restricted type of string value, which represent a common practice in Lua code: using a limited set of string constants to describe an enumeration of possible values.
You describe an enum like this:
local type Direction = enum
"north"
"south"
"east"
"west"
end
or like this:
local enum Direction
"north"
"south"
"east"
"west"
end
Variables and arguments of this type will only accept values from the declared list. Enums are freely convertible to strings, but not the other way around. You can of course promote an arbitrary string to an enum with a cast.
Functions
Functions in Teal should work like you expect, and we have already showed various examples.
You can declare nominal function types, like we do for records, to avoid
longwinded type declarations, especially when declaring functions that take
callbacks. This is done with using function
types, and they can be generic as
well:
local type Comparator = function<T>(T, T): boolean
local function mysort<A>(arr: {A}, cmp: Comparator<A>)
-- ...
end
Another thing to know about function declarations is that you can parenthesize the declaration of return types, to avoid ambiguities when using nested declarations and multiple returns:
f: function(function():(number, number), number)
You can declare functions that generate iterators which can be used in
for
statements: the function needs to produce another function that iterates.
This is an example taken the book "Programming in Lua":
local function allwords(): (function(): string)
local line = io.read()
local pos = 1
return function(): string
while line do
local s, e = line:find("%w+", pos)
if s then
pos = e + 1
return line:sub(s, e)
else
line = io.read()
pos = 1
end
end
return nil
end
end
for word in allwords() do
print(word)
end
The only changes made to the code above were the addition of type signatures in both function declarations.
Variadic functions
Just like in Lua, some functions in Teal may receive a variable amount of arguments. Variadic functions can be declared by specifying ...
as the last argument of the function:
local function test(...: number)
print(...)
end
test(1, 2, 3)
In case your function returns a variable amount of values, you may also declare variadic return types by using the type...
syntax:
local function test(...: number): number...
return ...
end
local a, b, c = test(1, 2, 3)
If your function is very dynamic by nature (for example, you are typing a
Lua function that can return anything), a typical return type will be any...
.
When using these functions, often one knows at the call site what are the
types of the expected returns, given the arguments that were passed. To set
the types of these dynamic returns, you can use the as
operator over
multiple values, using a parenthesized list of types:
local s = { 1234, "ola" }
local a, b = table.unpack(s) as (number, string)
print(a + 1) -- `a` has type number
print(b:upper()) -- `b` has type string
Union types
The language supports a basic form of union types. You can register a type that is a logical "or" of multiple types: it will accept values from multiple types, and you can discriminate them at runtime.
You can declare union types like this:
local a: string | number | MyRecord
local b: {boolean} | MyEnum
local c: number | {string:number}
To use a value of this type, you need to discriminate the variable, using
the is
operator, which takes a variable of a union type and one of its types:
local a: string | number | MyRecord
if a is string then
print("Hello, " .. a)
elseif a is number then
print(a + 10)
else
print(a.my_record_field)
end
As you can see in the example above, each use of the is
operator causes the
type of the variable to be properly narrowed to the type tested in its
respective block.
The flow analysis of is
also takes effect within expressions:
local a: string | number
local x: number = a is number and a + 1 or 0
Current limitations of union types
In the current version, there are two main limitations regarding support for union types in Teal.
The first one is that the is
operator always matches a variable, not arbitrary
expressions. This limitation is there to avoid aliasing
Since code generation for the is
operator used for
discrimination of union types translates into a runtime type()
check, we can
only discriminates across primitive types and at most one table type.
This means that these unions not accepted:
local invalid1: MyRecord | MyOtherRecord
local invalid2: {string} | {number}
local invalid3: {string} | {string:string}
local invalid4: {string} | MyRecord
Also, since is
checks for enums currently also translate into
type()
checks, this means they are indistinguishable from strings
at runtime. So, for now this is also not accepted:
local invalid5: string | MyEnum
This restriction between strings and enums may be removed in the future. The restriction on records may also be lifted in the future.
The type any
The type any
, as it name implies, accepts any value, like a
dynamically-typed Lua variable. However, since Teal doesn't know anything
about this value, there isn't much you can do with it, besides comparing for
equality and against nil, and casting it into other values using the as
operator.
Some Lua libraries use complex dynamic types that can't be easily represented
in Teal. In those cases, using any
and making explicit casts is our last
resort.
Variable attributes
Teal supports variable annotations, with similar syntax and behavior to those from Lua 5.4. They are:
Const variables
The <const>
annotation works in Teal like it does in Lua 5.4 (it works at
compile time, even if you're running a different version of Lua). Do note
however that this is annotation for variables, and not values: the contents
of a value set to a const variable are not constant.
local xs <const> = {1,2,3}
xs[1] = 999 -- ok! the array is not frozen
xs = {} -- Error! can't replace the array in variable xs
To-be-closed variables
The <close>
annotation from Lua 5.4 is only supported in Teal if your
code generation target is Lua 5.4. These work just
like they do in Lua 5.4.
local contents = {}
for _, name in ipairs(filenames) do
local f <close> = assert(io.open(name, "r"))
contents[name] = f:read("*a")
-- no need to call f:close() because files have a __close metamethod
end
Total variables
The <total>
annotation is specific to Teal. It declares a const variable
assigned to a table value in which all possible keys need to be explicitly
declared.
Of course, not all types allow you to enumerate all possible keys: there is an infinite number (well, not infinite because we're talking about computers, but an impractically large number!) of possible strings and numbers, so maps keyed by these types can't ever be total. Examples of valid key types for a total map are booleans (for which there are only two possible values) and, most usefully, enums.
Enums are the prime case for total variables: it is common to declare a
number of cases in an enum and then to have a map of values that handle
each of these cases. By declaring that map <total>
you can be sure that
you won't forget to add handlers for the new cases as you add new entries
to the enum.
local degrees <total>: {Direction:number} = {
["north"] = 0,
["west"] = 90,
["south"] = 180,
["east"] = 270,
}
-- if you later update the `Direction` enum to add new directions
-- such as "northeast" and "southwest", the above declaration of
-- `degrees` will issue a compile-time error, because the table
-- above is no longer total!
Another example of types that have a finite set of valid keys are records. By
marking a record variable as <total>
, you make it so it becomes mandatory to
declare all its fields in the given initialization table.
local record Color
red: integer
green: integer
blue: integer
end
local teal_color <total>: Color = {
red = 0,
green = 128,
blue = 128,
}
-- if you later update the `Color` record to add a new component
-- such as `alpha`, the above declaration of `teal_color`
-- will issue a compile-time error, because the table above
-- is no longer total!
Note however that the totality check refers only to the presence of
explicit declarations: it will still accept an assignment to nil
as a valid declaration. The rationale is that an explicit nil
entry
means that the programmer did consider that case, and chose to keep
it empty. Therefore, something like this works:
local vertical_only <total>: {Direction:MotionCallback} = {
["north"] = move_up,
["west"] = nil,
["south"] = move_down,
["east"] = nil,
}
-- This declaration is fine: the map is still total, as we are
-- explicitly mentioning which cases are left empty in it.
(Side note: the name "total" comes from the concept of a "total relation" in mathematics, which is a relation where, given a set of "keys" mapping to a set of "values", the keys fully cover the domain of their type).
Global variables
Unlike in Lua, global variables in Teal need to be declared, because the compiler needs to know its type. It also allows the compiler to catch typos in variable names, because an invalid name will not be assumed to be some unknown global that happens to be nil.
You declare global variables in Teal using global
, like this, doing
declaration and/or assignment:
global n: number
global m: {string:boolean} = {}
global hi = function(): string
return "hi"
end
global function my_function()
print("I am a global function")
end
You can also declare global types, which are visible across modules, as long as their definition has been previously required:
-- mymod.tl
local mymod = {}
global type MyPoint = record
x: number
y: number
end
return mymod
-- main.tl
local mymod = require("mymod")
local function do_something(p: MyPoint)
-- ...
end
If you have circular type dependencies that span multiple files, you can forward-declare a global type by specifying its name but not its implementation:
-- person.tl
local person = {}
global type Building
global record Person
residence: Building
end
return person
-- building.tl
local building = {}
global type Person
global record Building
owner: Person
end
return building
-- main.tl
local person = require("person")
local building = require("building")
local b: Building = {}
local p: Person = { residence = b }
b.owner = p