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 optionConfig keyTypeRelevant CommandsDescription
-l --require{string}runRequire a module prior to executing the script. This is similar in behavior to the -l flag in the Lua interpreter.
-I --include-dirinclude_dir{string}check gen runPrepend this directory to the module search path.
--gen-compatgen_compatstringgen runGenerate compatibility code for targeting different Lua VM versions. See below for details.
--gen-targetgen_targetstringgen runMinimum targeted Lua version for generated code. Options are 5.1, 5.3 and 5.4. See below for details.
--keep-hashbanggenPreserve hashbang line (#!) at the top of file if present.
-p --pretendgenDon't compile/write to any files, but type check and log what files would be written to.
--wdisabledisable_warnings{string}check runDisable the given warnings.
--werrorwarning_error{string}check runPromote the given warnings to errors.
--global-env-defglobal_env_defstringcheck gen runSpecify 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 wrapping require with a pcall, 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 for off above.
  • required - Teal will generate compatibility code which initializes compat53 with a plain require, 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 changing tl itself requires bootstrapping from a "working" version of tl.

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 As, 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.

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.