Introduction
Welcome to Teal!
This book is the primary source of documentation for the Teal programming language.
Teal is a statically-typed dialect of Lua. It extends Lua with type annotations, allowing you to specify arrays, maps and records, as well as interfaces, union types and generics.
It aims to fill a niche similar to that of TypeScript in the JavaScript world, but adhering to Lua's spirit of minimalism, portability and embeddability.
Is it implemented as a compiler, tl,
which compiles .tl
source code into .lua
files.
This project's goals
This project comes from the desire of a practical Lua dialect for programming-in-the-large. This is inspired by the experiences working on two large Lua applications.
The goal of the language is to be a dialect of Lua as much as TypeScript is a
dialect of JavaScript. So yes, it is on the one hand a different language
(there are new keywords such as global
for example) but on the other hand it
is pretty much recognizable as "Lua + typing".
It aims to integrate to pretty much any Lua environment, since it's a "transpiler" that generates plain Lua and has no dependencies. The goal is to support at least both the latest PUC-Rio Lua and the latest LuaJIT as output targets.
Minimalism (for some vague definition of minimalism!) is a design goal for both conceptual and practical reasons: conceptually to match the nature of Lua, and practical so that I can manage developing it. :)
My very first concrete goal for Teal's development was to have the compiler typecheck itself; that was achieved already: Teal is written in Teal.
The next big goal is to have it typecheck the source code of a complete Lua application such as LuaRocks. That's something I wanted since the Typed Lua days back in 2015. That's a big goal and once I get there I'll dare call this "production ready", since it's used in a real-world program, though it should be usable even before we get there! The language has already proven useful when creating a new Lua module.
Teal is being created in hopes it will be useful for myself and hopefully others. I'm trying to keep it small and long-term manageable, and would love to see a community of users and contributors grow around it over time!
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.
Programming with Types in Teal
Welcome to Teal!
In this tutorial chapter, 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.
Installing tl
To run tl, the Teal compiler, you need a Lua environment. Install Lua and LuaRocks (methods vary according to your operating system), and then run
luarocks install tl
If your environment is set up correctly, you should have a tl command available now!
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)
You can type-check it with
tl check add.tl
You can convert it to Lua with
tl gen add.tl
This will produce add.lua
. But you can also run it directly with
tl run add.tl
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
We can generate addsub.lua
with
tl gen addsub.tl
and then require the addsub module from Lua normally. Or we can load the Teal
package loader, which will allow require to load .tl files directly, without
having to run tl gen
first:
$ rm addsub.lua
$ lua
> tl = require("tl")
> tl.loader()
> addsub = require("addsub")
> print (addsub.add(10, 20))
When loading and running the Teal module from Lua, there is no type checking!
Type checking will only happen when you run tl check
or load a program with
tl run
.
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:
- enums
- records
- interfaces
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
-- an interface: an abstract record type
local interface Character
sprite: Image
position: Point
kind: string
end
-- records can implement interfaces, using a type-identifying `where` clause
local record Spaceship
is Character
where self.kind == "spaceship"
weapon: Weapons
end
-- a record can also declare an array interface, making it double as a record and an array
local record TreeNode<T>
is {TreeNode<T>}
item: T
end
-- a userdata record: a record which is implemented as a userdata
local record File
is userdata
status: function(): State
close: function(File): boolean, string
end
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 record Vector
x: number
y: number
end
local v1: Vector = { x = 100, y = 100 }
local p2: Point = v1 -- Error!
Just because a table has fields with the same names and types, it doesn't mean that it is a Point. This is because records in Teal are nominal types.
You can always force a type, though, using the as
operator:
local p2 = v1 as Point -- Teal goes "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 store array data, by declaring an array interface. You can use it both as a record, accessing its fields by name, and as an array, accessing its entries by number. A record can have only one array interface.
local record Node is {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)
Interfaces
Interfaces are, in essence, abstract records.
A concrete record is a type declared with record
, which can be used
both as a Lua table and as a type. In object-oriented terms, the record
itself works as class whose fields work as class attributes,
while other tables declared with the record type are objects whose
fields are object atributes. For example:
local record MyConcreteRecord
a: string
x: integer
y: integer
end
MyConcreteRecord.a = "this works"
local obj: MyConcreteRecord = { x = 10, y = 20 } -- this works too
An interface is abstract. It can declare fields, including those of
function
type, but they cannot hold concrete values on their own.
Instances of an interface can hold values.
local interface MyAbstractInterface
a: string
x: integer
y: integer
my_func: function(self, integer)
another_func: function(self, integer, self)
end
MyAbstractInterface.a = "this doesn't work" -- error!
local obj: MyAbstractInterface = { x = 10, y = 20 } -- this works
-- error! this doesn't work
function MyAbstractInterface:my_func(n: integer)
end
-- however, this works
obj.my_func = function(self: MyAbstractInterface, n: integer)
end
What is most useful about interfaces is that records can inherit
interfaces, using is
:
local record MyRecord is MyAbstractInterface
b: string
end
local r: MyRecord = {}
r.b = "this works"
r.a = "this works too because 'a' comes from MyAbstractInterface"
Note that the definition of my_func
used self
as a type name. self
is a valid type that can be used when declaring arguments in functions
declared in interfaces and records. When a record is declared to be a subtype
of an interface using is
, any function arguments using self
in the parent
interface type will then resolve to the child record's type. The type signature
of another_func
makes it even more evident:
-- the following function complies to the type declared for `another_func`
-- in MyAbstractInterface, because MyRecord is the `self` type in this context
function MyRecord:another_func(n: integer, another: MyRecord)
print(n + self.x, another.b)
end
Records and interfaces can inherit from multiple interfaces, as long as their component parts are compatible (that is, as long as the parent interfaces don't declare fields with the same name but different types). Here is an example showing how incompatible fields need to be stated explicitly, but compatible fields can be inherited:
local interface Shape
x: number
y: number
end
local interface Colorful
r: integer
g: integer
b: integer
end
local interface SecondPoint
x2: number
y2: number
get_distance: function(self): number
end
local record Line is Shape, SecondPoint
end
local record Square is Shape, SecondPoint, Colorful
get_area: function(self): number
end
--[[
-- this produces a record with these fields,
-- but Square also satisfies `Square is Shape`,
-- `Square is SecondPoint`, `Square is Colorful`
local record Square
x: number
y: number
x2: number
y2: number
get_distance: function(self): number
r: integer
g: integer
b: integer
get_area: function(self): number
end
]]
Keep in mind that this refers strictly to subtyping of interfaces, not
inheritance of implementations. For that reason, records cannot inherit from
other records; that is, you cannot use is
to do local record MyRecord is AnotherRecord
. You can define function fields in your interfaces and those
definitions will be inherited (as in the get_distance
and get_area
examples above), but you need to ensure that the actual implementations of
these functions are resolved at runtime the same way as they would do in Lua,
most likely using metatables to perform implementation inheritance. Teal
does not implement a class/object model of its own, as it aims to be compatible
with the multiple class/object models that exist in the Lua ecosystem.
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 } },
}
A type variable can be constrained by an interface, using is
:
local function largest_shape<S is Shape>(shapes: {S}): S
local max = 0
local largest: S
for _, s in ipairs(shapes) do
if s.area >= max then
max = s.area
largest = s
end
end
return largest
end
The benefit of doing this instead of largest_shape(shapes: {Shape}): Shape
is that, if you call this function passing, say, an array {Circle}
(assuming that record Circle is Shape
, Teal will infer S
to Circle
,
and that will be the type of the return value, while still allowing you
to use the specifics of the Shape
interface within the implementation of
largest_shape
.
Keep in mind though, the type variables are inferred upon their first match, so, especially when using constraints, that might demand additional care.
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
Note that functions can have optional arguments, as in the cmp?
example above.
This only affects the arity of the functions (that is, the number of arguments
passed to a function), not their types. Note that the question mark is assigned
to the argument name, not its type. If an argument is not optional, it may still
be given explicitly as nil
.
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(? string):(number, number), number)
Note also that in this example the string argument of the return function type is optional. When declaring optional arguments in function type declarations which do not use argument names, The question mark is placed ahead of the type. Again, this is an attribute of the argument position, not of the argument type itself.
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.
Teal also supports macro expressions, which are a restricted form of function whose contents are expanded inline when generating Lua code.
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
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.
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
local ok: boolean
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!
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
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 (see the compiler options
documentation for details on code generation targets). 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. Note that you can only use <total>
when assigning to a literal
table value, that is, when you are spelling out a table using a {}
block.
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).
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.
Macro expressions
Teal supports a restricted form of macro expansion via the macroexp
construct, which declares a macro expression. This was added to the
language as the support mechanism for implementing the where
clauses
in records and interfaces, which power the type resolution performed
by the is
operator.
Macro expressions are always expanded inline in the generated Lua code. The declaration itself produces no Lua code.
A macro expression is declared similarly to a function, only using
macroexp
instead of function
:
local macroexp add(a: number, b: number)
return a + b
end
There are two important restrictions:
- the body of the macro expression can only contain a single
return
statement with a single expression; - each argument can only be used once in the macroexp body.
The latter restriction allows for macroexp calls to be expanded inline in any expression context, without the risk for producing double evaluation of side-effecting expressions. This avoids the pitfalls commonly produced by C macros in a simple way.
Because macroexps do not generate code on declaration, you can also declare a macroexp inline in a record definition:
local record R
x: number
get_x: function(self): number = macroexp(self: R): number
return self.x
end
end
local r: R = { x = 10 }
print(r:get_x())
This generates the following code:
local r: R = { x = 10 }
print(r.x)
You can also use them for metamethods: this will cause the metamethod to be expanded at compile-time, without requiring a metatable:
local record R
x: number
metamethod __lt: function(a: R, b: R) = macroexp(a: R, b: R)
return a.x < b.x
end
end
local r: R = { x = 10 }
local s: R = { x = 20 }
if r > s then
print("yes")
end
This generates the following code:
local r = { x = 10 }
local s = { x = 20 }
if s.x < r.x then
print("yes")
end
This is used to implement the pseudo-metamethod __is
, which is used to
resolve the is
operator. The where
construct is syntax sugar to an
__is
declaration, meaning the following two constructs are equivalent:
local record MyRecord is MyInterface
where self.my_field == "my_record"
end
-- ...is the same as:
local record MyRecord is MyInterface
metamethod __is: function(self: MyRecord): boolean = macroexp(self: MyRecord): boolean
return self.my_field == "my_record"
end
end
At this time, macroexp declarations within records do not allow inference,
so the function
type needs to be explicitly declared when implementinga
a field or metamethod as a macroexp
. This requirement may be dropped in
the future.
Behavior rules
This chapter describes in greater detail the various behaviors of the type system.
Type aliasing rules in Teal
The general rule
In Teal we can declare new types with user-defined names. These are called nominal types. These nominal types may be unique, or aliases.
The local type
syntax produces a new nominal type. Whenever you assign to
it another user-defined nominal type, it becomes a type alias. Whenever you
assign to it a type constructor, it becomes a new unique type. Type
constructors are syntax constructs such as: block constructors for records,
interfaces and enums (e.g. record
... end
); function signature
declarations with function()
; applications of generics with <>
-notation;
declarations of array, tuple or map types with {}
-notation; or a primitive
type name such as number
.
Syntax such as local record R
is a shorthand to local type R = record
, so
the same rules apply: it declares a new unique type.
Nominal types are compared against each other by name, but type aliases are considered to be equivalent.
local record Point3D
x: number
y: number
z: number
end
local record Vector3D
x: number
y: number
z: number
end
local p: Point3D = { x = 1.0, y = 0.3, z = 2.5 }
local v: Vector3D = p -- Teal compile error: Point3D is not a Vector3D
local type P3D = Point3D
local p2: P3D
p2 = p -- ok! P3D is a type alias type Point3D
p = p2 -- ok! aliasing works both ways: they are effectively the same type
Nominal types are compared against non-nominal types by structure, so that you can manipulate concrete values, which have inferred types. For example, you can assign a plain function to a nominal function type, as long as the signatures are compatible, and you can assign a number literal to a nominal number type.
local type MyFunction = function(number): string
-- f has a nominal type
local f: MyFunction
-- g is inferred a structural type: function(number): string
local g = function(n: number): string
return tostring(n)
end
f = g -- ok! structural matched against nominal
g = f -- ok! nominal matched against structural
You can declare structural types for functions explicitly:
local type MyFunction = function(number): string
-- f has a nominal type
local f: MyFunction
-- h was explicitly given a structural function type
local h: function(n: number): string
f = h -- ok!
h = f -- ok!
By design, there is no syntax in Teal for declaring structural record types.
Some examples
Type aliasing only happens when declaring a new user-defined nominal type using an existing user-defined nominal type.
local type Record1 = record
x: integer
y: integer
end
local type Record2 = Record1
local r1: Record1
assert(r1 is Record2) -- ok!
This does not apply to primitive types. Declaring a type name with the same primitive type as a previous declaration is not an aliasing operation. This allows you to create types based on primitive types which are distinct from each other.
local type Temperature = number
local type TemperatureAlias = Temperature
local type Width = number
local temp: Temperature
assert(temp is TemperatureAlias) -- ok!
assert(temp is Width) -- Teal compile error: temp (of type Temperature) can never be a Width
Like records, each declaration of a function type in the program source code
represents a distinct type. The function(...):...
syntax for type
declaration is a type constructor.
local type Function1 = function(number): string
local type Function2 = function(number): string
local f1: Function1
assert(f1 is Function2) -- Teal compile error: f1 (of type Function2) can never be a Function1
However, user-defined nominal names referencing those function types can be aliased.
local type Function1 = function(number): string
local type Function3 = Function1
local f1: Function1
assert(f1 is Function3) -- ok!
Type variable matching
When Teal type-checks a generic function call, it infers any type variables based on context. Type variables can appear in function arguments and return types, so these are matched with the information available at the call site:
- the place where the function call is made is used to infer type variables in return types;
- the values passed as arguments are used to infer type variables appearing in function arguments.
For example, given a generic function with the following type:
local my_f: function<T, U>(T): U
...the following call will infer T
to boolean
and U
to string
.
local s: string = my_f(true)
Note that each type variable is inferred upon its first match, and return types are inferred first, then argument types. This means that if the type signature was instead this:
local my_f: function<T>(T): T
then the call above would fail with an error like argument 1: got boolean, expected string
.
Matching multiple type variables to types requires particular care when
type variables with is
-constraints are used multiple types. Consider
the following example, which probably does not do what you want:
local interface Shape
area: number
end
local function largest_shape<S is Shape>(a: S, b: S): S
if a.area > b.area then
return a
else
return b
end
end
When attempting to use this with different kinds of shapes at the same time, we will get an error:
local record Circle is Shape
end
local record Square is Shape
end
local c: Circle = { area = 10 }
local s: Square = { area = 20 }
local l = largest_shape(c, s) -- error! argument 2: Square is not a Circle
The type variable S
was matched to c
first. We can instead do this:
local function largest_shape<S is Shape, T is Shape>(a: S, b: T): S | T
if a.area > b.area then
return a
else
return b
end
end
But then we have to make records that can be discriminated in a union,
by giving their definitions where
clauses. This is a possible solution:
-- we add a `name` to the interface
local interface Shape
name: string
area: number
end
local function largest_shape<S is Shape, T is Shape>(a: S, b: T): S | T
if a.area > b.area then
return a
else
return b
end
end
-- we add `where` clauses to Circle and Square
local record Circle
is Shape
where self.name == "circle"
end
local record Square
is Shape
where self.name == "square"
end
-- we add the `name` fields so that the tables conform to their types;
-- in larger programs this would be typically done in constructor functions
local c: Circle = { area = 10, name = "circle" }
local s: Square = { area = 20, name = "square" }
local l = largest(c, s)
...which results in l
having type Circle | Square
.
Pragmas
Teal is evolving as a language. Sometimes we need to add incompatible changes to the language, but we don't want to break everybody's code at once. The way to deal with this is by adding pragmatic annotations (typically known in compiler lingo as "pragmas") that tell the compiler about how to interpret various minutiae of the language, in practice picking which "dialect" of the language to use. This lets the programmer pedal back on certain language changes and adopt them gradually as the existing codebase is converted to the new version.
Let's look at a concrete example where pragmas can help us: function arity checks.
Function arity checks
If you're coming from an older version of Teal, it is possible that you will start getting lots of errors related to numbers of arguments, such as:
wrong number of arguments (given 2, expects 4)
This is because, up to Teal 0.15.x, the language was lenient on the arity of
function calls (the number of expressions passed as arguments in the call). It
would just assume that any missing arguments were intended to be nil
on
purpose. More often than not, this is not the case, and a missing argument
does not mean that the argument was optional, but rather that the programmer
forgot about it (this is common when adding new arguments during a code
refactor).
Teal now features optional function arguments. if an argument can be
optionally elided, you now can, or rather, have to, annotate it explicitly
adding a ?
to its name:
local function greet(greeting: string, name?: string)
if name then
print(string.format("%s, %s!", greeting, name))
else
print(greeting .. "!")
end
end
greet("Hello", "Teal") --> Hello, Teal!
greet("Hello") --> Hello!
greet() --> compile error: wrong number of arguments (given 0, expects at least 1 and at most 2)
However, there are many Teal libraries out there (and Lua libraries for which .d.tl type declaration files were written), which were prepared for earlier versions of Teal.
The good news is that you don't have to convert all of them at once, neither you have to make an all-or-nothing choice whether to have or not those function arity checks.
You can enable or disable arity checks using the arity
pragma. Let's first
assume we have an old library written for older versions of Teal:
-- old_library.tl
local record old_library
end
-- no `?` annotations here, but `name` is an optional argument
function old_library.greet(greeting: string, name: string)
if name then
print(string.format("%s, %s!", greeting, name))
else
print(greeting .. "!")
end
end
return old_library
Now we want to use this library with the current version of Teal, but we don't want to lose arity checks in our own code. We can temporarily disable arity checks, require the library, then re-enable them:
--#pragma arity off
local old_library = require("old_library")
--#pragma arity on
local function add(a: number, b: number): number
return a + b
end
print(add(1)) -- compile error: wrong number of arguments (given 1, expects 2)
old_library.greet("Hello", "Teal") --> Hello, Teal!
-- no compile error here, because in code loaded with `arity off`,
-- every argument is optional:
old_library.greet("Hello") --> Hello!
-- no compile error here as well,
-- even though this call will crash at runtime:
old_library.greet() --> runtime error: attempt to concatenate a nil value (local 'greeting')
The arity
pragma was introduced as a way to gradually convert codebases, as
opposed to the wholesale approach of passing --feat-arity=off
to the
compiler command-line or setting feat_arity = "off"
in tlconfig.lua
, the
compiler options file.
Optional arities versus optional values
Note that arity checks are about the number of expressions used as arguments
in function calls: it does not check whether the values are nil
or not.
In the above example, even with arity check enabled, you could still write
greet(nil, nil)
and that would be accepted by the compiler as valid,
even though it would crash at runtime.
Explicit checking for nil
is a separate feature, which may be added in a
future version of Teal. When that happens, we will definitely need a pragma
to allow for gradual adoption of it!
What pragmas are not
One final word about pragmas: there is no well-established definition for a "compiler pragma" in the literature, even though this is a common term.
It's important to clarify here that Teal pragmas are not intended as
general-purpose annotations (the kind of things you usually see with @-
syntax in various other languages such as C#, Java or #[]
in Rust). Pragmas
here are intended as compiler directives, more akin to compiler flags (e.g.
the #pragma
use in C compilers).
In short, our practical goal for pragmas is to allow for handling compatibility issues when dealing with the language evolution. That is, in a Teal codebase with no legacy concerns, there should be no pragmas.
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.
The second one is that Teal only accepts unions over a set of types that
it can discriminate at runtime, so that it can generate code for the
is
operator properly. That means we can either only use one table
type in a union, or, if we want to use multiple table types in a union,
they need to be records or interfaces that were declared with a where
annotation to discriminate them.
This means that these unions not accepted:
local invalid1: {string} | {number}
local invalid2: {string} | {string:string}
local invalid3: {string} | MyRecord
However, the following union can be accepted, if we declare the record
types with where
annotations:
local interface Named
name: string
end
local record MyRecord is Named
where self.name == "MyRecord"
end
local record AnotherRecord is Named
where self.name == "AnotherRecord"
end
local valid: MyRecord | AnotherRecord
A where
clause is any Teal expression that uses the identifier self
at most once (if you need to use it multiple times, you can always write
a function that implements the discriminator expression and call that
in the where
clause passing self
as an argument).
Note that Teal has no way of proving at compile time that the set of where
clauses in the union is actually disjoint and can discriminate the values
correctly at runtime. Like the other aspects of setting up a Lua-based
object model, that is up to you.
Another limitation on is
checks comes up with enums, since these also
translate into type()
checks. This means they are indistinguishable from
strings at runtime. So, for now these are also not accepted:
local invalid4: string | MyEnum
local invalid5: MyEnum | AnotherEnum
This restriction on enums may be removed in the future.
Using tl with Lua
You can use tl to type-check not only Teal programs, but Lua programs too! When
type-checking Lua files (with the .lua extension or a Lua #!
identifier in
the first line), the type-checker adds support for an extra type:
- unknown
which is the type of all non-type-annotated variables. This means that in a Lua file you can declare untyped variables as usual:
local x -- invalid in .tl, valid but unknown in .lua
When processing .lua files, tl will report no errors involving unknown
variables. Anything pertaining unknown variables is, well, unknown. Think of .tl
files as the safer, "strict mode", and .lua files as the looser "lax mode".
However, even a Lua file with no annotations whatsoever will still have a bunch
of types: every literal value (numbers, strings, arrays, etc.) has a type.
Variables initialized on declaration are also assumed to keep consistent types
like in Teal. The types of the Lua standard library are also known to tl: for
example, the compiler knows that if you run table.concat
on a table, the only
valid output is a string.
Plus, requiring type-annotated modules from your untyped Lua program will also help tl catch errors: tl can check the types of calls from Lua to functions declared as Teal modules, and will report errors as long as the input arguments are not of type unknown.
Having unknown variables in a Lua program is not an error, but it may hide
errors. Running tl check
on a Lua file will report every unknown variable in
a separate list from errors. This allows you to see which parts of your program
tl is helpless about and help you incrementally add type annotations to your
code.
Note that even though adding type annotations to .lua files makes it invalid
Lua, you can still do so and load them from the Lua VM once the Teal package
loader is installed by calling tl.loader()
.
You can also create declaration files to annotate the types of third-party Lua modules, including C Lua modules. For more information, see the declaration files page.
Type definitions for third party libraries
You can create declaration files to annotate the types of third-party Lua
modules, including C Lua modules. To do so, create a file with the .d.tl
extension and require it as normal, i.e. local lfs = require("lfs")
.
Types defined in this module will will be used as a source of type information
checking with tl check
, even though the real Lua module will be loaded
instead when requiring the module from Lua or tl run
.
Visibility
There are two ways to define these types:
Composite Types
local record MyCompositeType
record MyPointType
x: number
y: number
end
center: MyPointType
-- insert more stuff here
end
return MyCompositeType
This will mean that references to MyPointType
must be qualified (or locally declared) as
MyCompositeType.MyPointType
.
Global Types
global record MyPointType
x: number
y: number
end
global record MyCompositeType
center: MyPointType
end
These can now be used unqualified in any file that requires them.
Global environment definition
Some customized Lua environments predefine some values into the Lua VM
space as global variables. An example of an environment
which presents this behavior is LÖVE,
which predefines a love
global table containing its API. This global is
just "there", and code written for that environment assumes it is available,
even if you don't load it with require
.
To make the Teal compiler aware of such globals, you can define them
inside a declaration file, and tell the compiler to load the declaration module into its own type
checking environment, using the --global-env-def
flag in the CLI or the
global_env_def
string in tlconfig.lua
.
For example, if you have a file called love-example.d.tl
containing the
definitions for LÖVE:
-- love-example.d.tl
global record love
record graphics
print: function(text: string, x: number, y: number)
end
end
You can put global_env_def = "love-example"
in a tlconfig.lua
file at
the root of your project, and tl
will now assume that any globals declared
in love-example.d.tl
are available to other modules being compiled:
-- tlconfig.lua
return {
global_env_def = "love-example"
}
Example usage:
-- main.tl
love.graphics.print("hello!", 0, 0)
$ tl check main.tl
========================================
Type checked main.tl
0 errors detected
Note that when using tl gen
, this option does not generate code for the
global environment module, and when using tl run
it does not execute the
module either. This option is only meant to make the compiler aware of any
global definitions that were already loaded into a customized Lua VM.
Reusing existing declaration files (and contributing new ones!)
The Teal Types repo contains declaration files for some commonly-used Lua libraries.
Feel free to check it out and make your contribution!
The Teal Standard Library and Lua compatibility
tl supports a fair subset of the Lua 5.3 standard library (even in other Lua versions, using compat-5.3), avoiding 5.3-isms that are difficult to reproduce in other Lua implementations.
It declares all entries of the standard library as <const>
, and assumes that
Lua libraries don't modify it. If your Lua environment modifies the standard
library with incompatible behaviors, tl will be oblivious to it and you're on
your own.
The Teal compiler also supports Lua-5.3-style bitwise operators (&
, |
, ~
,
<<
, >>
) and the integer division //
operator on all supported Lua
versions. For Lua versions that do not support it natively, it generates code
using the bit32 library, which is also included in compat-5.3 for Lua 5.1.
You can explicitly disable the use of compat-5.3 with the --skip-compat53
flag and equivalent option in tlconfig.lua
. However, if you do so, the Lua
code generated by your Teal program may not behave consistently across different
target Lua versions, and differences in behavior across Lua standard libraries
will reflect in Teal. In particular, the operator support described above may
not work.
Compiler options
tl
supports some compiler options. These can either be specified on the command line or inside a tlconfig.lua
file.
Project configuration
When running tl
, the compiler will try to read the compilation options from a file called tlconfig.lua
inside the current working directory.
Here is an example of a tlconfig.lua
file:
return {
include_dir = {
"folder1/",
"folder2/"
},
}
List of compiler options
Command line option | Config key | Type | Relevant Commands | Description |
---|---|---|---|---|
-l --require | {string} | run | Require a module prior to executing the script. This is similar in behavior to the -l flag in the Lua interpreter. | |
-I --include-dir | include_dir | {string} | check gen run | Prepend this directory to the module search path. |
--gen-compat | gen_compat | string | gen run | Generate compatibility code for targeting different Lua VM versions. See below for details. |
--gen-target | gen_target | string | gen run | Minimum targeted Lua version for generated code. Options are 5.1 , 5.3 and 5.4 . See below for details. |
--keep-hashbang | gen | Preserve hashbang line (#! ) at the top of file if present. | ||
-p --pretend | gen | Don't compile/write to any files, but type check and log what files would be written to. | ||
--wdisable | disable_warnings | {string} | check run | Disable the given warnings. |
--werror | warning_error | {string} | check run | Promote the given warnings to errors. |
--global-env-def | global_env_def | string | check gen run | Specify a definition module declaring any custom globals predefined in your Lua environment. See the declaration files page for details. |
Generated code
Teal is a Lua dialect that most closely resembles Lua 5.3-5.4, but it is able
to target Lua 5.1 (including LuaJIT) and Lua 5.2 as well. The compiler attempts
to produce code that, given an input .tl
file, generates the same behavior
on various Lua versions.
However, there are limitations in the portability across Lua versions, and the
options --gen-target
and --gen-compat
give you some control over the generated
code.
Target version
The configuration option gen_target
(--gen-target
in the CLI) allow you to
choose what is the minimum Lua version you want to target. Valid options are
5.1
(for Lua 5.1 and above, including LuaJIT) and 5.3
for Lua 5.3 and above.
Using 5.1
, Teal will generate compatibility code for the integer division operator,
a compatibility forward declaration for table.unpack
and will use the bit32
library for bitwise operators.
Using 5.3
, Teal will generate code using the native //
and bitwise operators.
The option 5.4
is equivalent to 5.3
, but it also allows using the <close>
variable annotation. Since that is incompatible with other Lua versions, using
this option requires using --gen-compat=off
.
Code generated with --gen-target=5.1
will still run on Lua 5.3+, but not
optimally: the native Lua 5.3+ operators have better performance and better
precision. For example, if you are targeting Lua 5.1, the Teal code x // y
will generate math.floor(x / y)
instead.
If you do not use these options, the Teal compiler will infer a default target implicitly.
Which Lua version does the Teal compiler target by default?
If set explicitly via the --gen-target
flag of the tl
CLI (or the equivalent
options in the programmatic API), the generated code will target the Lua
version requested: 5.1, 5.3 or 5.4.
If the code generation target is not set explicitly via --gen-target
, Teal
will target the Lua version most compatible with the version of the Lua VM
under which the compiler itself is running. For example, if running under
something that reports _VERSION
as "Lua 5.1"
or "Lua 5.2"
(such as LuaJIT),
it will generate 5.1-compatible code. If running under Lua 5.3 or greater, it
will output code that uses 5.3 extensions.
The stand-alone tl
binaries are built using Lua 5.4, so they default to
generating 5.3-compatible code. If you install tl
using LuaRocks, the CLI
will use the Lua version you use with LuaRocks, so it will default to that
Lua's version.
If you require the tl
Lua module and use the tl.loader()
, it will do the
implicit version selection, picking the right choice based on the Lua version
you're running it on.
Compatibility wrappers
Another source of incompatibility across Lua versions is the standard library. This is mostly fixable via compatibility wrappers, implemented by the compat53 Lua library.
Teal's own standard library definition as used by its type checker most closely resembles that of Lua 5.3+, and the compiler's code generator can generate code that uses compat53 in order to produce consistent behavior across Lua versions, at the cost of adding a dependency when running on older Lua versions. For Lua 5.3 and above, compat53 is never needed.
To avoid forcing a dependency on Teal users running Lua 5.1, 5.2 or LuaJIT,
especially those who take care to avoid incompatibilities in the Lua standard
library and hence wouldn't need compat53 in their code, Teal offers three
modes of operation for compatibility wrapper generation via the gen_compat
flag (and --gen-compat
CLI option):
off
- you can choose to disable generating compatibility code entirely. When type checking, Teal will still assume the standard library is 5.3-compatible. If you run the Teal module on an older Lua version and use any functionality from the standard library that is not available on that version, you will get a runtime error, similar to trying to run Lua 5.3 code on an older version.optional
(default) - Teal will generate compatibility code which initializes the the compat53 library wrappingrequire
with apcall
, so that it doesn't produce a failure if the library is missing. This means that, if compat53 is installed, you'll get the compliant standard library behavior when running on Lua 5.2 and below, but if compat53 is missing, you'll get the same behavior as described foroff
above.required
- Teal will generate compatibility code which initializes compat53 with a plainrequire
, meaning that you'll get a runtime error when loading the generated module from Lua if compat53 is missing. You can use this option if you are distributing the generated Lua code for users running different Lua versions and you want to ensure that your Teal code behaves the same way on all Lua versions, even if at the cost of an additional dependency.
Global environment definition
To make the Teal compiler aware of global variables in your execution environment,
you may pass a declaration module to the compiler using the --global-env-def
flag
in the CLI or the global_env_def
string in tlconfig.lua
.
For more information, see the declaration files page.
Hacking on tl itself
As correctly pointed out in #51:
Creating and testing edits to
tl.tl
can feel a bit awkward because changingtl
itself requires bootstrapping from a "working" version oftl
.
Keeping tl working
Because of this situation, the tl repository provides a Makefile that
conveniently runs a build and test while making sure that tl.lua
, which
is the file that ultimately drives the currently-running compiler, keeps
working.
So, when working on tl.tl
, instead of running tl gen tl.tl
, run make
.
This will run tl gen tl.tl
, but it will also make a backup of tl.lua
first, and it will check that the new modified version can still build itself.
If anything goes wrong, it reverts tl.lua
to the backup and your compiler
still works. If the modified compiler is able to rebuild itself, then
it will run the Busted test suite. If the Busted test suite fails, it will
not revert tl.lua
, but leave you with the buggy compiler (i.e. a tl.lua
that matches the behavior of your current version of tl.tl
).
If you want to revert only the generated code back to the last committed
state in Git but keep your changes to tl.tl
around, you can run
git checkout tl.lua
.
Avoid circular dependencies
When dealing with a bootstrapped project (a project that uses itself to run), one has to always be careful to not make the code itself depend on a new feature when implementing it, otherwise you get into a chicken-and-egg situation.
For example, when generics were added, the code to support them had to be
written using non-generic types, resorting to any
and ugly casts. Once
the tests for generics were passing, then the code of tl.tl
itself was
modified to use it.
If you find yourself in a circular-dependency situation like this (sometimes
it's a bug you need fixed in the compiler and the compiler needs the bug
fixed to run correctly), the last-resort alternative is to copy the fix
manually to tl.lua
, stripping out the types in your new code by hand,
then running both (you may want to save your changes in a backup commit
before trying it, as you might accidentally overwrite your manual changes!).
Again, this manual editing of tl.lua
shouldn't generally be necessary
if you take care to not depend on work-in-progress features.
Sending code contributions
When submitting a pull request, make sure you include in your commits both the
changes to tl.tl
and tl.lua
. They should match of course (the tl.lua
should be the product of compiling tl.tl
). In general, Git repositories do
not contain generated files, but we keep both in the repository precisely to
avoid the chicken-and-egg bootstrapping situation (if we didn't, one would
have to have a previous tl
installation already in order to run tl
from a
Git repo clone).
When sending a PR that adds a new feature or fixes a bug, please add one or more
relevant tests to the Busted test suite under spec/
. Adding tests is important
to demonstrate that the PR works and help future maintenance of the project,
as it will check automatically that the change introduced in the PR will keep
working in the future as other changes are made to the project. For bug fixes,
the ideal test is a regression test: a test that would fail when running with
the unmodified version of the compiler, but passes when running the corrected
compiler.
The Grammar of Teal
Here is the complete syntax of Teal in extended BNF, based on the Lua 5.4
grammar. Lines starting with +
are existing lines from the Lua grammar
with additions; lines marked with *
are entirely new.
As usual in extended BNF, {A}
means 0 or more A
s, and [A]
means an
optional A
. For a description of the terminals Name, Numeral, and
LiteralString, see Section 3.1 of the Lua 5.3 Reference
Manual. For operator
precedence, see below.
chunk ::= block
block ::= {stat} [retstat]
stat ::= ‘;’ |
varlist ‘=’ explist |
functioncall |
label |
‘break’ |
‘goto’ Name |
‘do’ block ‘end’ |
‘while’ exp ‘do’ block ‘end’ |
‘repeat’ block ‘until’ exp |
‘if’ exp ‘then’ block {‘elseif’ exp ‘then’ block} [‘else’ block] ‘end’ |
‘for’ Name ‘=’ exp ‘,’ exp [‘,’ exp] ‘do’ block ‘end’ |
‘for’ namelist ‘in’ explist ‘do’ block ‘end’ |
‘function’ funcname funcbody |
+ ‘local’ attnamelist [‘:’ typelist] [‘=’ explist] |
‘local’ ‘function’ Name funcbody |
* ‘local’ ‘record’ Name recordbody |
* ‘local’ ‘interface’ Name recordbody |
* ‘local’ ‘enum’ Name enumbody |
* ‘local’ ‘type’ Name ‘=’ newtype |
* ‘global’ attnamelist ‘:’ typelist [‘=’ explist] |
* ‘global’ attnamelist ‘=’ explist |
* ‘global’ ‘function’ Name funcbody |
* ‘global’ ‘record’ Name recordbody |
* ‘global’ ‘interface’ Name recordbody |
* ‘global’ ‘enum’ Name enumbody |
* ‘global’ ‘type’ Name [‘=’ newtype]
attnamelist ::= Name [attrib] {‘,’ Name [attrib]}
attrib ::= ‘<’ Name ‘>’
retstat ::= ‘return’ [explist] [‘;’]
label ::= ‘::’ Name ‘::’
+ funcname ::= Name {‘.’ Name} ‘:’ Name | Name {‘.’ Name} ‘.’ Name
varlist ::= var {‘,’ var}
var ::= Name | prefixexp ‘[’ exp ‘]’ | prefixexp ‘.’ Name
namelist ::= Name {‘,’ Name}
explist ::= exp {‘,’ exp}
exp ::= ‘nil’ | ‘false’ | ‘true’ | Numeral | LiteralString | ‘...’ | functiondef |
prefixexp | tableconstructor | exp binop exp | unop exp |
* exp ‘as’ type | exp ‘as’ ‘(’ typelist ‘)’ | Name ‘is’ type
prefixexp ::= var | functioncall | ‘(’ exp ‘)’
functioncall ::= prefixexp args | prefixexp ‘:’ Name args
args ::= ‘(’ [explist] ‘)’ | tableconstructor | LiteralString
functiondef ::= ‘function’ funcbody
+ funcbody ::= [typeargs] ‘(’ [parlist] ‘)’ [‘:’ retlist] block ‘end’
+ parlist ::= parnamelist [‘,’ ‘...’ [‘:’ type]] | ‘...’ [‘:’ type]
tableconstructor ::= ‘{’ [fieldlist] ‘}’
fieldlist ::= field {fieldsep field} [fieldsep]
field ::= ‘[’ exp ‘]’ ‘=’ exp |
+ Name [‘:’ type] ‘=’ exp |
exp
fieldsep ::= ‘,’ | ‘;’
binop ::= ‘+’ | ‘-’ | ‘*’ | ‘/’ | ‘//’ | ‘^’ | ‘%’ |
‘&’ | ‘~’ | ‘|’ | ‘>>’ | ‘<<’ | ‘..’ |
‘<’ | ‘<=’ | ‘>’ | ‘>=’ | ‘==’ | ‘~=’ |
‘and’ | ‘or’
unop ::= ‘-’ | ‘not’ | ‘#’ | ‘~’
* type ::= ‘(’ type ‘)’ | basetype {‘|’ basetype}
* nominal ::= Name {{‘.’ Name }} [typeargs]
* basetype ::= ‘string’ | ‘boolean’ | ‘nil’ | ‘number’ |
* ‘{’ type {',' type} ‘}’ | ‘{’ type ‘:’ type ‘}’ | functiontype
* | nominal
* typelist ::= type {‘,’ type}
* retlist ::= ‘(’ [typelist] [‘...’] ‘)’ | typelist [‘...’]
* typeargs ::= ‘<’ Name {‘,’ Name } ‘>’
* newtype ::= ‘record’ recordbody | ‘enum’ enumbody | type
* | ‘require’ ‘(’ LiteralString ‘)’ {‘.’ Name }
* interfacelist ::= nominal {‘,’ nominal} |
* ‘{’ type ‘}’ {‘,’ nominal}
* recordbody ::= [typeargs] [‘is’ interfacelist]
* [‘where’ exp] {recordentry} ‘end’
* recordentry ::= ‘userdata’ |
* ‘type’ Name ‘=’ newtype | [‘metamethod’] recordkey ‘:’ type |
* ‘record’ Name recordbody | ‘enum’ Name enumbody
* recordkey ::= Name | ‘[’ LiteralString ‘]’
* enumbody ::= {LiteralString} ‘end’
* functiontype ::= ‘function’ [typeargs] ‘(’ partypelist ‘)’ [‘:’ retlist]
* partypelist ::= partype {‘,’ partype}
* partype ::= Name [‘?’] ‘:’ type | [‘?’] type
* parnamelist ::= parname {‘,’ parname}
* parname ::= Name [‘?’] [‘:’ type]
Operator precedence
Operator precedence in Teal follows the table below, from lower to higher priority:
or
and
is
< > <= >= ~= ==
|
~
&
<< >>
..
+ -
* / // %
unary operators (not # - ~)
^
as
As usual, you can use parentheses to change the precedences of an expression.
The concatenation (..
) and exponentiation (^
) operators are right
associative. All other binary operators are left associative.
Other related projects
Here is a short overview of other projects related to Lua and types, prompted by this question at Github:
- Typed Lua was a research project started as @andremm's PhD thesis at PUC-Rio, co-advised by @mascarenhas which also got contributions from some students over time. It was a big exploration of optional typing for Lua and lots of lessons were learned there (see the various published papers!). The focus being on research results, the implementation itself for most of the time remained more of a proof-of-concept of this research, rather than a practical tool. The original developers moved on to other things, and the repo hasn't had updates in a year.
- Titan started as a community project between myself (maintainer of LuaRocks and a PUC-Rio alumnus), the Typed Lua devs mentioned above, plus grad students at PUC-Rio @hugomg and @gligneul who at the time were studying optional typing and compilation techniques. The idea was to join forces and build something that would make everyone happy: I wanted a Lua for programming-in-the-large, the Typed Lua people wanted a typed dialect of Lua, and grad students wanted a high-performance Lua. The "companion language" concept seemed to fit the bill, being a "Lua-ish language you'd use instead of falling back to C or doing contortions to please the JIT compiler gods".
- Pallene started as a fork of Titan because, as you can guess, design-by-committee is challenging, especially when people in the committee have different goals. :) Since grad students work under a deadline to produce results, the ideal situation was to have the academic side of Titan fork into its own project, which is currently active, healthy and continues with the evolution of the "companion language" concept. Titan went dormant as the others moved on: and as far as I'm concerned, Titan is now dead but I hope it will prove to have been a worthwhile kickstarting effort once Pallene starts to bear fruit.
So tl;dr:: as of 2020, Typed Lua and Titan aren't active, Pallene is a project aiming to generate native-code modules for use with the Lua interpreter, and Teal is a dialect of Lua plus types implemented as a transpiler which outputs Lua.