Typing In Tua¶
Tua is gradually typed Lua. Type information improves diagnostics, completion,
hover, navigation, rename, signature help, and inlay hints, then disappears from
the generated Lua. You can type a whole subsystem, annotate only its public
boundaries, or deliberately leave dynamic values as any.
This guide explains Tua's type system as a whole. See the
Language Guide for the complete syntax reference and
Lua Interoperability for describing existing .lua code with
LuaDoc.
Start With Boundaries¶
Tua infers straightforward locals, so useful annotations usually belong on function parameters and returns, shared state, class fields, callbacks, and module APIs:
type Player = {
name: string,
health: number,
}
local function damage(player: Player, amount: number): number
player.health -= amount
return player.health
end
local player: Player = {
name = "Mina",
health = 100,
}
The emitted Lua contains the same tables and functions without the annotations or alias declaration. Types do not add runtime wrappers or change table layout.
Type Forms¶
Primitives And any¶
The primitive types are number, string, boolean, and nil:
local score: number = 0
local title: string = "Arena"
local active: boolean = true
local missing: nil = nil
any is the gradual escape hatch. Values can be assigned to and from any, so
use it at intentionally dynamic boundaries rather than as a default:
An any value gives up guarantees downstream. When an unexpected any appears
in VS Code, run Tua: Explain Type / Why Is This Any? to trace its source.
Aliases And Table Shapes¶
A type alias gives a reusable name to a type and emits no runtime code:
type Vec2 = {
x: number,
y: number,
}
type Player = {
position: Vec2,
health: number,
nickname?: string,
}
Table compatibility is structural: a value satisfies Player when its
required fields have compatible types. Optional fields may be absent, but must
have the declared type when present.
Inside an alias, Self refers to that alias. This is useful for recursive data:
Arrays, Dictionaries, And Tuples¶
Use T[] for an array, { [Key]: Value } for a dictionary, and [T, U] for a
fixed positional tuple:
local checkpoints: Vec2[] = {}
local scores: { [string]: number } = {}
local spawn: [number, number] = { 80, 120 }
An empty table is a valid initializer when an array or dictionary annotation
provides its element types. Array element types flow through supported indexed
reads and writes, table.insert, ipairs, and common loop patterns.
Parenthesize a union or function type before making it an array:
Unions, Nilable Values, And Literal Choices¶
A union accepts a value compatible with any member:
Literal string unions model a closed set of choices and power string-value completion:
For a longer named list, an erased enum is equivalent to a literal union:
Function Types¶
Function declarations use annotations after parameter names and after the parameter list:
Callback types use (parameters) -> returns:
The parenthesized return list in Decoder represents multiple Lua return
values. Parameter names are retained for editor presentation but do not affect
compatibility.
Trailing parameters can be optional or have a default:
local function label(player: Player, prefix?: string): string
return (prefix or "Player") .. ": " .. player.name
end
local function heal(player: Player, amount: number = 5)
player.health += amount
end
Use ...: Type in a declaration and ...Type in a function type for typed
varargs:
local function logAll(prefix: string, ...: string)
print(prefix, ...)
end
type Logger = (prefix: string, ...string) -> nil
Generics are not currently part of Tua. Use arrays, dictionaries, tuples,
concrete aliases, and any at genuinely dynamic boundaries.
Inference And Assignment¶
An unannotated local with a statically known initializer gets a stable, widened type for that lexical binding:
local score = 10 -- number
score = 12 -- accepted
score = "high" -- error: expected number
local state = "idle" -- string, not the exact literal "idle"
state = "running" -- accepted
The binding keeps that contract across branches and loops. A shadowing local
creates a new binding rather than changing the outer one.
Lua initializes a declaration without a value to nil, so declare intended
optional state explicitly:
Use an annotation when an empty table, nil, or another broad initializer does
not contain enough information to express the intended contract.
Narrowing¶
Nil checks narrow optional values inside the guarded branch:
local function playerName(player: Player | nil): string
if player ~= nil then
return player.name
end
return "none"
end
Use value is Type when Tua can emit an equivalent runtime identity check:
local function stringify(value: string | number): string
if value is string then
return value
end
return "" .. value
end
Primitive is checks lower to Lua type(...) comparisons. Visible metatable
classes and native Tua classes use their supported runtime identity mechanisms.
Erased structural aliases, arrays, dictionaries, and arbitrary table shapes
cannot be tested at runtime and are rejected as is targets.
Narrowing is branch-local. It does not permanently change the declared or inferred type after control flow rejoins.
Sharing Types Between Modules¶
Declare a public alias with export type. Consume it with either a namespace
or named type-only import:
export type Player = {
name: string,
health: number,
}
export type Room = {
name: string,
spawn: [number, number],
}
type InternalScratch = { id: number }
import type Shared from "./types"
import type { Player, Room as LevelRoom } from "./types"
local current: LevelRoom | nil = nil
local function enter(player: Player, room: Shared.Room)
current = room
end
Namespace imports keep ownership visible (Shared.Room) and are a clear
default when a module exports several related types. Named imports reduce noise
when only one or two types are used.
Build public facades with type re-exports:
Only aliases marked export type cross a Tua module boundary. This rule is the
same whether the consumer uses import type or reaches the module through a
runtime require or import binding. Imported names cannot collide with local
type aliases or runtime declarations.
Declaration-Only .d.tua Files¶
Use .d.tua for modules that describe types but have no runtime value:
- they are parsed, checked, indexed, watched, and available to editor features;
- they support exported aliases, type imports, enums, and type re-exports;
- runtime statements are rejected;
tua buildemits no.luafile or source map for them;- type-only edges do not introduce runtime module loads or runtime require cycles.
A regular .tua file may also export types alongside runtime code. Its types
are erased while its runtime statements are emitted normally. Choose .d.tua
when the entire module is a declaration surface.
Type re-export cycles receive a dedicated diagnostic. Recursive structural types can still refer across declaration modules; recursion is kept bounded during checking and display.
Structural And Nominal Types¶
Aliases for table shapes are structural. Independently declared shapes with compatible required fields can be assigned to one another.
Native Tua classes are nominal across module boundaries. Two unrelated classes
do not become compatible merely because their fields happen to match. Class
fields, constructor parameters, method parameters, and returns use the same
type forms documented above. See Classes for inheritance,
Self, super, and override rules.
Tua also recognizes a conservative subset of common Lua metatable patterns.
Prefer explicit aliases or native classes when a stable public contract matters;
dynamic metatable construction may intentionally fall back to any.
Checking And Editor Feedback¶
Run the project checker without emitting Lua:
Type errors are always reported by tua check and block tua build by default.
The linter is a separate layer for configurable code-quality rules; see
CLI and Linter.
The VS Code extension uses the same semantic model as the checker:
- hover shows resolved and inferred types;
- completion uses table fields, module exports, literals, and narrowed values;
- go-to-definition follows aliases and type-only imports;
- signature help presents typed parameters and returns;
- inlay hints expose inferred local, parameter, and return types;
- Tua: Explain Type / Why Is This Any? traces inference and gradual boundaries.
Type names are completed only in type positions. Type-only imports create no runtime local and therefore do not appear as runtime values.
Practical Guidelines¶
- Annotate API boundaries and important state before annotating every local.
- Use literal unions for closed string choices and
T | nilfor optional state. - Give empty arrays, dictionaries, and initially nil values an explicit type.
- Keep related public contracts in a
.d.tuadeclaration module. - Prefer namespace type imports when provenance is useful; use named imports for a small, frequently used set.
- Confine
anyto the smallest dynamic boundary and translate it into typed data early. - Use a runtime validator when untrusted data enters the program. Erased types do not validate files, network payloads, or user input at runtime.
Current Boundaries¶
Tua intentionally remains gradual and does not try to prove arbitrary Lua
behavior. Expect conservative results around dynamically computed table keys,
runtime-generated modules, _G, complex metatable mutation, and unknown
third-party values. Generics and runtime checks for erased structural types are
not currently supported.
For a plain .lua boundary, add supported LuaDoc annotations or a configured
declaration-only type library. Those mechanisms are documented in
Lua Interoperability and
Configuration.