Impressions of Teal

ibobev1 pts0 comments

Impressions of Teal | purplesyringa's blog<br>Impressions of Teal<br>July 26, 2026About two months ago, I found the Teal programming language, which describes itself as a statically-typed dialect of Lua. It transpiles to Lua and runs typecheck on the developer’s machine, so it fills roughly the same niche as TypeScript. It’s used by quite a few projects, including LuaRocks and inspect.For better or worse, I am trying to write Lua without feeling like it’s year 2010, and Teal promised to be suitable for this purpose.This post is a retrospective on my experience: what went right and what, in my opinion, could be improved. Mostly the latter.Disclaimer: while I stumbled upon multiple issues, I deeply respect everyone who works on this project, and I consider it a valuable tool. No offence intended.Annotations<br>I think the main selling point of Teal is just how simple its annotations are. Look at the example on the website:local function add(a: number, b: number): number<br>return a + b<br>end

local s: number = add(1, 2)<br>print(s)<br>It’s concise and non-intrusive, like TypeScript. Compare it to LuaLS annotations, which offers a competitive Lua typing project:---@param a number<br>---@param b number<br>---@return number<br>local function add(a, b)<br>return a + b<br>end

---@type number<br>local s = add(1, 2)<br>print(s)<br>Nasty!Soundness<br>This is where its obvious advantages end, though. As we’ll find out in a bit, the simplicity is quite fragile.I’m a Rust-pilled developer, so I expect some degree of soundness from type systems. “Soundness” means that if the type checker says the program is fine, you shouldn’t encounter type errors in runtime. Sounds easy?But soundness is fundamentally difficult to achieve in dynamic languages due to variance. The issue arises when mutability and subtypes collide. Here’s an example:Suppose that some function f takes a parameter x: number[].Outside the function, create a list a: integer[]. You should seemingly be able to pass it as an argument to f, since every integer is a number: f(a).From the perspective of f, x is number[], so it can append 1.5 to the list.But this causes the variable a: integer[] to contain a non-integer value 1.5!The only real way to solve this is with readonly annotations, like in TypeScript. Teal doesn’t offer anything of similar functionality. It also doesn’t have nil safety, not even opt-in, so e.g. all table accesses return T, not T | nil.I won’t hold this specific issue against Teal, as even TypeScript supports bivariance by default and doesn’t have nil safety for arrays, but it does hint that Teal considers soundness – which I’d call the main point of typing – an afterthought.Unions<br>Teal has very rudimentary support for discriminated unions (or sum types, or enums with fields). For comparison, here’s what that looks like in Rust:enum Enum {<br>Variant1 {<br>a: String,<br>},<br>Variant2 {<br>a: i32.<br>b: i32,<br>},<br>…and in TypeScript, which simulates them with union types:interface Variant1 {<br>type: "variant1"<br>a: string

interface Variant2 {<br>type: "variant2"<br>a: number<br>b: number

type Enum = Variant1 | Variant2<br>Note that we have to explicitly add a field of a literal type to disambiguate types in runtime. Teal supports neither first-class sum types, nor literal types, so you have to tell it how to disambiguate variants explicitly – otherwise you can’t make a type union:local record Variant1<br>where self.type == "variant1"<br>type: string<br>a: string<br>end

local record Variant2<br>where self.type == "variant2"<br>type: string<br>a: number<br>b: number<br>end

local type Enum = Variant1 | Variant2<br>where denotes a type predicate. It can contain arbitrary code, and the compiler doesn’t validate that the definition actually makes sense. So you have to write the equivalent of unsafe code every time you want to define a discriminated union!Furthermore, Teal can’t validate that newly created instances of Variant* have the right types, because type is just a string. To enforce that, you have to use single-variant enums to simulate some aspects of literal types:local enum Variant1Type "variant1" end<br>local record Variant1<br>where self.type == "variant1" -- still required, but at least type-checked<br>type: Variant1Type<br>a: string<br>end

...

-- Error: string "variatn1" is not a variant of Variant1Type<br>local x: Variant1 = {<br>type = "variatn1", -- hypothetical misspelling<br>a = "b",<br>Suffice to say, this is messier than it should be. But all the underlying mechanisms are already there, so hopefully Teal supports sum types as a first-class citizen one day.is<br>Teal adds two operators to the language: as and is. as is an unchecked typecast that gets optimized away in runtime, and is checks if a variable has a given runtime type.The main use for is is pattern-matching union types:local function f(x: Enum)<br>-- Doesn't work, says `x` has type `Variant1 | Variant2` even inside `if` branches.<br>if x.type == "variant1" then<br>local y: Variant1 = x<br>else<br>local y: Variant2 = x<br>end

-- Works correctly, substitutes the predicate in `where`.<br>if x is Variant1 then<br>local y:...

type number variant1 teal local types

Related Articles