Teal: Typed Lua That Compiles to Lua
Lua is dynamically typed. For small scripts, that is fine. For large codebases, it gets harder to track what functions expect and what they return — typos become runtime errors, refactors break silently, and onboarding new developers requires reading code carefully to understand data shapes.
Teal addresses this by adding a type system to Lua, then compiling that typed code back to plain Lua. The result is a Lua dialect where you can catch type errors before running, but the output runs anywhere standard Lua runs: Lua 5.1 through 5.4, and LuaJIT.
Teal was created by Hisham Muhammad (the creator of LuaRocks), inspired by his experience maintaining large Lua applications at Kong and LuaRocks. The compiler itself is written in Teal.
Installation
luarocks install tl
This puts the tl command in your path. Teal has no runtime dependencies — the compiler is a single tl.lua file you can also load directly into a Lua project. The compiler is itself written in Teal and bootstrapped, which means every release of Teal is type-checked by the previous version before being published. If you cannot use LuaRocks, you can clone the Teal repository and add tl.lua to your project manually.
Your first typed code
Here is a simple Lua function:
local function add(a, b)
return a + b
end
This function works fine in isolation, but in a larger codebase the caller has to guess that a and b should be numbers. Passing a string by accident would cause a runtime error that only surfaces when that specific code path executes — possibly in production. Teal eliminates this class of bug by letting you declare types upfront, so the compiler catches the mistake before any code runs.
In Teal, you annotate the types:
local function add(a: number, b: number): number
return a + b
end
The type annotations sit after the parameter names and before the closing parenthesis. The return type annotation after the parameters tells the compiler that this function will always produce a number — and if the body ever produces a different type, the compiler will refuse to emit Lua output until the mismatch is fixed.
Run it directly:
tl run add.tl
The tl run command type-checks the file first, then executes the compiled Lua immediately. If the type check fails, execution is skipped entirely and you see the error on the terminal — no time wasted on a script that would have crashed anyway. This is the fastest way to develop, but you can also separate the steps.
Or compile it to Lua:
tl gen add.tl
# produces add.lua
The generated add.lua is just plain Lua, no Teal dependency at runtime. The type annotations are completely stripped from the output, so the generated code looks and behaves exactly like hand-written Lua — there is no runtime type checking overhead, no extra library to ship, and no performance penalty for using types.
Type annotations
Variables need explicit types in Teal. You annotate at declaration:
local x: number = 10
local name: string = "Alice"
local items: {string} = {"one", "two", "three"}
local ok: boolean = true
If you omit the type annotation, Teal infers it from the value. If you omit the value, you must annotate:
local count: integer -- type is required without an initializer
count = 10 -- ok
Records: structuring data
Teal uses record to define structured types (similar to structs):
local record Point
x: number
y: number
end
local record Rectangle
x: number
y: number
width: number
height: number
end
local function area(r: Rectangle): number
return r.width * r.height
end
local r: Rectangle = { x = 0, y = 0, width = 10, height = 5 }
print(area(r)) -- 50
Records are structural: if a table has the right fields, it satisfies the type, even if it was not created with the record constructor. This is a key difference from nominal type systems like Java or C#. In Teal, you do not need to declare that Rectangle “implements” anything — any table with x, y, width, and height fields of the correct types is automatically a valid Rectangle.
Interfaces
Use interface for types that describe what methods an object must have:
local interface Drawable
draw: function(self: Drawable)
x: number
y: number
end
local record Circle
x: number
y: number
radius: number
end
function Circle:draw()
print("drawing circle at " .. self.x .. "," .. self.y)
end
local function render(d: Drawable)
d:draw()
end
Interfaces let you decouple behaviour from implementation. The render function accepts any type that satisfies Drawable — it does not care whether the drawable is a Circle, Rectangle, or any future type you define. This is the same structural typing philosophy as records: if a table has a draw method with the right signature, it satisfies the interface automatically. You never write implements Drawable — the compiler infers it.
Generics
Write functions that work over multiple types:
local function identity<T>(x: T): T
return x
end
print(identity<number>(42)) -- 42
print(identity<string>("hello")) -- hello
Generics also work with records:
local record Box<T>
value: T
end
local function wrap<T>(x: T): Box<T>
return { value = x }
end
Generic records let you build reusable data structures — a Box of numbers, a Box of strings, or a Box of any custom record — all with full type safety. Teal instantiates the generic at compile time, so the generated Lua is monomorphised: wrap<number>(42) produces code specific to numbers with no runtime type dispatch overhead.
Optional arguments
Add ? to an argument name to make it optional:
local function greet(name: string, greeting?: string)
local g = greeting or "Hello"
print(g .. ", " .. name .. "!")
end
greet("Alice") -- Hello, Alice!
greet("Bob", "Good morning") -- Good morning, Bob!
The ? belongs to the argument name, not the type. An optional argument may be omitted or passed as nil. The compiler tracks optional arguments through the call graph, so if you call a function without providing an optional argument, the type checker knows the parameter is nil and will flag any attempt to use it without a nil guard. This is especially useful for configuration-heavy APIs where most parameters have sensible defaults.
Enums
Define a fixed set of named values:
local enum Direction
"north"
"east"
"south"
"west"
end
local function move(dir: Direction): Direction
if dir == "north" then return "south"
elseif dir == "east" then return "west"
end
return dir
end
Enums in Teal are string-typed at runtime. The compiler checks that only declared enum values are used, so typos like "nroth" instead of "north" are caught at compile time. Unlike enums in C or Java, Teal enums do not map to integers — each value is the literal string, which makes them self-documenting in logs and debug output.
Arrays and maps
Teal arrays use {type} syntax:
local nums: {number} = {1, 2, 3, 4, 5}
local names: {string} = {"Alice", "Bob", "Carol"}
Maps use {keytype: valuetype}:
local scores: {string: number} = {
Alice = 100,
Bob = 85,
Carol = 92
}
Teal’s map type is checked for both keys and values, so inserting scores[42] = "high" would fail type checking — the key must be a string and the value must be a number. This is a dramatic improvement over untyped Lua, where a typo in a table key silently creates a new field with nil as its value. Teal catches these mistakes before they become runtime bugs that are notoriously hard to track down.
Function types
Type annotations for functions, not just function declarations:
local type Comparator<T> = function(T, T): boolean
local function max<T>(a: T, b: T, cmp: Comparator<T>): T
if cmp(a, b) then
return a
end
return b
end
This is especially useful for callbacks and higher-order functions. By naming the comparator type once and reusing it, you avoid repeating function(T, T): boolean in every function signature that accepts a comparison callback. Consistent function type aliases across a codebase make it easier to refactor: if you change the comparator contract, you update the type alias and the compiler flags every function that no longer matches.
The compiler CLI
Once tl is installed:
tl run script.tl # run directly
tl check module.tl # type-check and report errors, don't run
tl gen module.tl # compile to module.lua (types stripped)
tl warnings # list all available warnings
For project-level settings, create a tlconfig.lua file at your project root. The config file lets you set global compiler options like include paths, warning levels, and the Lua version to target. This is the standard way to configure Teal for a multi-file project — without it, each file would need to be compiled with the correct flags specified on the command line every time.
Loading Teal from Lua
Instead of precompiling, you can load .tl files at runtime:
local tl = require("tl")
tl.loader() -- registers the .tl loader
-- Now require() works with .tl files
local mymodule = require("mymodule") -- loads mymodule.tl
This is useful when you want to distribute .tl source and let the end user’s Lua environment compile it on the fly. The runtime loader approach is convenient for open-source libraries — end users get type safety during development, and the compilation happens once when the module is first loaded. Just keep in mind that the user must have the tl rock installed for the loader to function, which adds a dependency that precompilation avoids.
Declaration files for external libraries
Teal supports declaration files to type-check code that uses third-party Lua libraries. These are analogous to TypeScript’s .d.ts files:
# The teal-types repository has shared declaration files
https://github.com/teal-language/teal-types
You write a .d.tl file declaring the types of the library you are using, and Teal uses it for type checking. A declaration file lists function signatures, record definitions, and type aliases for the library’s public API without containing any implementation. The community-maintained teal-types repository covers many popular Lua libraries like LuaSocket and LuaFileSystem, so you often do not need to write declaration files from scratch.
Type aliasing: nominal vs structural
When you write local type MyPoint = Point, you create a type alias — MyPoint and Point are the same type. When you write local record MyPoint end (or local type MyPoint = record ... end), you create a distinct nominal type, even if the fields match.
This matters when you want two records with identical shapes to be distinct:
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, y = 2, z = 3 }
local v: Vector3D = p -- compile error: Point3D is not a Vector3D
Both have the same shape, but Teal treats them as different nominal types. Use local type aliasing if you want them to be interchangeable.
When Teal is a better fit than Luau
Luau (Roblox’s typed Lua) is great for Roblox development but not portable. Teal produces standard Lua that runs anywhere:
- OpenResty / Nginx Lua — type-check your API handlers
- Game engines — as long as the engine supports standard Lua
- Embedded Lua — type-check before deploying to constrained environments
- Existing Lua codebases — add Teal incrementally to catch bugs
Teal also has no runtime dependency: pre-compile .tl to .lua and distribute only the Lua output.
See Also
- lua-moonscript-intro — another compile-to-Lua language, focused on cleaner syntax rather than types
- lua-closures — closures, used heavily in Teal code especially for callbacks and higher-order functions
- lua-metatables — the table-level OOP model that Teal’s records and interfaces build on top of