v0.13.8

Types

main · 4b0211c · latest release v0.13.8source on GitHub ↗

Types catch a misspelled field, a wrong argument or a nil before you /reload. This page covers annotations, inference, the checking modes, narrowing, table and function types, generics, the typed WoW API and declaration files.

Add a type

A type goes after a colon: after a local's name, after a parameter's name, and after the parameter list for what the function returns.

src/repair.wowluawowlua
local function repairCost(useGuild: boolean): number    local cost: number, canRepair: boolean = GetRepairAllCost()    if not canRepair then        return 0    end    if useGuild and CanGuildBankRepair() then        RepairAllItems(true)    end    return costend print(repairCost(false))

The compiler checks the types and then removes them. The Lua it writes is the Lua you would have written without them:

lua
local function repairCost(useGuild)	local cost, canRepair = GetRepairAllCost()	if not canRepair then		return 0	end	if useGuild and CanGuildBankRepair() then		RepairAllItems(true)	end	return costend print(repairCost(false))

Classes and enums are the exception: they are values, so they produce code. Type aliases, interfaces and :: assertions produce none (Types §2.10).

Let wowlua work it out

Most locals need no annotation. A local takes the type of the value you assign to it, and a function without a return type returns whatever its return statements give.

src/session.wowluawowlua
local kills = 0                          -- numberlocal zone = GetZoneText()               -- stringlocal stats = { gold = 0, deaths = 0 }   -- { gold: number, deaths: number }local names = { "Thrall", "Jaina" }      -- string[] local function perHour(amount: number, seconds: number)    return amount / seconds * 3600       -- returns numberend stats.gold += perHour(1200, 600)print(kills, zone, names[1], stats.gold)

The inferred types are checked like written ones. Use the result of perHour where a string is expected and the checker knows it's a number:

error[E2001]: `number` is not assignable to `string`
   ┌─ src/session.wowlua:11:7
   │
11 │ local rate: string = perHour(stats.gold, 600)
   │       ^^^^ here

error: aborting due to 1 previous error

A local keeps the type of the value you declare it with. Assigning a value of another type later is an error, just as it would be with the type written out:

wowlua
local test = 3          -- numbertest = "hello world"    -- E2001: `"hello world"` is not assignable to `number`print(`test: {test}`)

local test: number = 3 reports the same. When a local should hold both, say so with an annotation: local test: number | string = 3. A local declared without a value (local test) or with nil has nothing to take a type from: it holds whatever you assign to it, and each use sees the value assigned last. A literal widens (local done = false is a boolean, so done = true fits), and a value with any type leaves the local any.

In strict mode nil doesn't fit such a local unless its value could already be nil: write local timer: TimerHandle? = C_Timer.NewTimer(5, f) for one you clear later. In a plain Lua file, one without wowlua syntax, the mismatch is the warning W2001: the example above with print(test) as its last line is plain Lua, and reports W2001.

A parameter without a type is any, unless the function is a callback whose parameters the call already fixes (see the game's API). Hover a name in VS Code to see the type wowlua inferred for it.

Start loose, tighten later

Each file is checked in one of three modes:

Mode What it checks
basic (the default) Annotated and inferred types. Parameters without a type are any, nil fits every type, and an undeclared global is a warning.
strict Everything basic checks, plus nil safety (string never holds nil, write string?), undeclared globals are errors, parameters of functions that are not callbacks need types (E2050), and a sealed table can't gain new fields (E2051).
nocheck No type checking. Syntax and names only.

A file that uses no wowlua syntax, such as a plain Lua file renamed to .wowlua, never fails the check. Its name and type errors are reported as warnings with the same number: E2001 becomes W2001, E1001 becomes W1001. Only syntax errors stay errors, because the game could not load the file either. .lua files are not type-checked at all unless check.lua is set in the manifest, and then only with warnings.

Set the mode for the whole addon in wowlua.json, and list files that are checked in strict mode whatever the mode is:

json
"check": {    "mode": "basic",    "strict": ["src/core/**"]}

A directive before the first line of code sets the mode of one file: --!strict, --!basic or --!nocheck.

src/core/db.wowluawowlua
--!strictlocal function load(name: string): string    return `{name}DB`end print(load("MyAddon"))

The manifest reference has the other check keys, and Types §2.1 the exact rules.

Handle values that may be nil

T? means "a T or nil". In strict mode you must deal with the nil before you use the value. An if, an early return or assert(x) is enough: after it, the checker knows the value is there.

src/guild.wowluawowlua
--!strictlocal function guildLabel(): string    local guild, rank = GetGuildInfo("player")    if not guild then        return "no guild"    end    -- guild is a string from here on    return rank and `<{guild}> {rank}` or guild:upper()end print(guildLabel())

Leave out the if and the error points at the spot:

error[E2005]: `string?` may be `nil`
  ┌─ src/guild.wowlua:4:49
  │
4 │     return rank and `<{guild}> {rank}` or guild:upper()
  │                                                 ^^^^^ here

error: aborting due to 1 previous error

In basic mode nil fits every type, so this is not reported there. Leaving out an argument the function needs is reported in both modes (see Functions).

Accept more than one kind of value

A | B is a value of either type. The checker follows your tests and narrows the type: after type(spell) == "number", spell is a number, and after the return, a string.

src/spell.wowluawowlua
--!strictlocal function spellName(spell: number | string): string    if type(spell) == "number" then        local info = C_Spell.GetSpellInfo(spell)        return info and info.name or tostring(spell)    end    return spell                       -- a string hereend print(spellName(774))

Tables with a field that holds a literal, a tag, narrow the same way. Compare the tag and the fields of the matching type are there:

src/ledger.wowluawowlua
--!stricttype Earned = { kind: "earned", amount: number, source: string }type Spent = { kind: "spent", amount: number, vendor: string } local function describe(entry: Earned | Spent): string    if entry.kind == "earned" then        return `+{entry.amount} from {entry.source}`    end    return `-{entry.amount} at {entry.vendor}`end print(describe({ kind = "spent", amount = 120, vendor = "Arnok" }))

Reading entry.vendor before the comparison is E2003 in strict mode, because an Earned has no vendor. The other tests that narrow are truthiness, comparisons with nil, assert, functions that check a type (Functions) and tests whether a global exists (Types §2.5).

pcall works the same way. Its first result says whether the function returned. If it did, the other results are the function's. If it raised an error, the second result is that error and the rest are nil. Test the first result before you use the others:

src/scene.wowluawowlua
--!strictlocal ok, scene = pcall(CreateFrame, "ModelScene")if ok then    scene:Show()                       -- a ModelScene hereelse    print(`no scene: {tostring(scene)}`) -- the error hereend

Describe tables

Most addon data lives in tables. These forms cover lists, lookups and records:

src/raid.wowluawowlua
local members: string[] = {}                       -- a listlocal roleOf: { [string]: string } = {}            -- a lookup by namelocal pull: { boss: string, started: number, note?: string } = {    boss = "Ragnaros",    started = GetTime(),}local frozen: { readonly id: number } = { id = 42 } -- can't be assigned print(#members, roleOf["Thrall"], pull.boss, frozen.id)
  • T[] is a list. Reading members[i] gives a string.
  • { [K]: V } is a map. Reading it gives V?, because the key may be missing.
  • { name: T } is a record. note?: string marks a field that may be absent.
  • A readonly field can't be assigned (E2010).
  • Records are structural: any table with the fields the type needs fits, wherever it was made. Extra fields are allowed.
  • In strict mode, a field the record type doesn't have is E2003, both when you read it and when you write it in a table literal assigned to that type, so { bos = "Ragnaros" } is caught.

An unannotated local that holds a table literal stays open while the function that made it fills it in: assigning stats.total = 0 adds a field to its type. Reading a field it doesn't have yet gives any. In strict mode, reading a field the file never stores in it, such as a misspelled stats.deths, is E2003.

Give a type a name

type gives a name to a type you use in several places. typeof(expr) is the type of a value, so a defaults table you write once also gives you its type.

src/settings.wowluawowlua
local DEFAULTS = { scale = 1, locked = false, anchor = "CENTER" }type Settings = typeof(DEFAULTS)     -- { scale: number, locked: boolean, anchor: string } type ItemID = numbertype OnUpdate = (frame: Frame, elapsed: number) -> () local function apply(frame: Frame, s: Settings)    frame:SetScale(s.scale)end local function get<K extends keyof Settings>(s: Settings, key: K): Settings[K]    return s[key]end local id: ItemID = 19019local scale = get(DEFAULTS, "scale")  -- numberapply(UIParent, DEFAULTS)print(id, scale)

An alias is only another name: ItemID and number are interchangeable. keyof Settings is the union of the field names, "scale" | "locked" | "anchor", and Settings[K] is the type of field K. Together they type a getter like get above: get(DEFAULTS, "scael") is E2014, and the result of get(DEFAULTS, "scale") is a number.

Add export to use an alias in other files (export type Settings = …, see Modules). A type or interface declared inside a function or a do block belongs to that block, like a local. An alias may refer to itself inside a table or function type, as in type Tree = { value: number, children: Tree[] }; anywhere else (type Node = Node | nil) it is E2053.

Allow only certain values

A string or number literal is a type of its own, with exactly that one value. A union of literals works like an enum of WoW's string constants. The game's anchor points, event names and units are typed this way.

src/anchor.wowluawowlua
type Corner = "TOPLEFT" | "TOPRIGHT" | "BOTTOMLEFT" | "BOTTOMRIGHT" local function dock(frame: Frame, corner: Corner)    frame:ClearAllPoints()    frame:SetPoint(corner, UIParent, corner, 0, 0)end local panel = CreateFrame("Frame", nil, UIParent)dock(panel, "TOPLEFT")

A typo in the corner is caught at the call:

error[E2001]: `"TOPLFET"` is not assignable to `"TOPLEFT" | "TOPRIGHT" | "BOTTOMLEFT" | "BOTTOMRIGHT"`
   ┌─ src/anchor.wowlua:10:13
   │
10 │ dock(panel, "TOPLFET")
   │             ^^^^^^^^^ here

error: aborting due to 1 previous error

When the values are your own, an enum gives them names.

Write code that works for any type

A type parameter in angle brackets lets one function keep the types of what you pass it. wowlua infers it at each call. There is no syntax to write it out, because f<number>(x) is already a valid comparison in Lua.

src/util.wowluawowlua
local members: string[] = { "Thrall", "Jaina" }local roleOf: { [string]: string } = { Thrall = "TANK" } local function first<T>(list: T[], pred: (value: T) -> boolean): T?    for _, v in ipairs(list) do        if pred(v) then return v end    end    return nilend local tank = first(members, function(name) return roleOf[name] == "TANK" end)  -- string?print(tank)

<T extends { size: number }> limits what may be passed: an argument that doesn't fit is E2014. Generic type aliases, interfaces and classes take parameters the same way. Functions and Classes have more.

The game's API is typed

Every function, widget method, event and constant of the game is typed, for each flavor you build for. Browse them in the WoW API reference. A callback you pass to the API gets its parameter types from the API, so even code without a single annotation is checked:

src/fade.wowluawowlua
local frame = CreateFrame("Frame", nil, UIParent)   -- Frameframe:SetScript("OnUpdate", function(self, elapsed)    -- self: Frame, elapsed: number, without writing either    self:SetAlpha(math.max(0, self:GetAlpha() - elapsed))end) local bar = CreateFrame("StatusBar")                -- StatusBarbar:SetMinMaxValues(0, UnitHealthMax("player"))

CreateFrame gives the type of the frame you ask for, so bar has the StatusBar methods.

Testing whether a global exists narrows it. Inside if C_EditMode then … end, calls into C_EditMode are accepted, even in a flavor whose API doesn't have it, so the feature checks you already write keep working. Build info and profiles covers code that differs by flavor.

Secret values in combat

Since Midnight, many API results are secret values to addon code in combat, in encounters and in Mythic+. You can store a secret, pass it on, concatenate it, and hand it to the widget setters that accept secrets. Arithmetic, comparisons, #, indexing and calling it raise an error at once. wowlua types such a result as T | Secret<T> and reports a forbidden use when you write it, not when it breaks in a raid.

src/healthbar.wowluawowlua
--!strictlocal bar = CreateFrame("StatusBar", nil, UIParent)local warning = bar:CreateTexture() local function update(unit: string)    local hp = UnitHealth(unit)                 -- number | Secret<number>    bar:SetValue(hp)                            -- fine: SetValue accepts secrets     if canaccessvalue(hp) and hp < 1000 then    -- fine: a plain number here        warning:Show()    endend update("target")

canaccessvalue(v) and issecretvalue(v) narrow: where the value can be read, the Secret part is gone. Compare hp without the check and it's E2040 in strict mode:

error[E2040]: `number | Secret<number>` may be a secret value; narrow it with `canaccessvalue` or `issecretvalue` first
   ┌─ src/healthbar.wowlua:12:8
   │
12 │     if hp < 1000 then warning:Show() end
   │        ^^ here

In basic mode and in plain Lua files it's the warning W2040.

A widget can hold a secret too. Once one of its setters gets a secret (SetText, SetAlpha, SetValue, SetScale, …), the matching getters (GetText, IsShown, GetWidth, GetAlpha, GetPoint and many more) return secrets for that widget. The checker can't tell which of your widgets got one, so these getters are typed plainly (GetWidth() is a number), and the emulated client in wowlua test returns secrets where the game does.

To have them typed as Blizzard documents them, T | Secret<T>, set "secret-widget-results": true under check in wowlua.json. Then every use needs a check first, which pays off in code that feeds widgets from secret sources, such as a health bar filled from UnitHealth:

src/health.wowluawowlua
--!strictlocal bar = CreateFrame("StatusBar", nil, UIParent) local function percent(): number    local value = bar:GetValue()  -- number | Secret<number> with the setting    local _, max = bar:GetMinMaxValues()    if issecretvalue(value) or issecretvalue(max) or max == 0 then        return 0    end    return value / max * 100end print(percent())

The rules are in Types §2.11.

Tell wowlua what you know

value :: Type tells the checker the type of a value it can't see, such as a frame a template created under a global name. It costs nothing at run time: the compiler removes it.

src/template.wowluawowlua
local addonName = ...type SavedSettings = { scale: number } local button = _G["MyAddonCloseButton"] :: Buttonbutton:SetText("Close") local db = _G[addonName .. "DB"] :: SavedSettingsprint(db.scale)

An assertion must make sense: one of the two types must fit the other, or both are tables. label :: Frame with a string label is E2015. When you really mean it, go through any: label :: any :: Frame.

:: binds tighter than every binary operator, so write (x :: number) + 1 (Syntax and sugar).

Type your addon table, globals and Lua libraries

A .d.wowlua file holds only declarations and produces no code. Use one to type your addon table, the globals other addons create, or a plain Lua library you embed. Its declarations are visible in every file of the addon.

src/types.d.wowluawowlua
type Settings = { scale: number, locked: boolean } declare interface AddonNamespace    db: Settings    version: stringend declare global MyAddonDB: Settings declare function RangeKit_GetRange(unit: UnitToken): (number?, number?)

AddonNamespace is the type of the addon table WoW passes each file as the second value of ...:

src/core.wowluawowlua
local addonName, ns = ...ns.db.scale = 2          -- checked against Settingsns.loaded = true         -- never declared: any local minRange, maxRange = RangeKit_GetRange("target")print(addonName, MyAddonDB.locked, minRange, maxRange)

All declare interface AddonNamespace blocks merge into one, wherever they are. Fields you never declared stay any, so existing code keeps working. declare namespace, declare class, declare event and declare module are in Types §2.9.

Classic Lua objects are understood

You don't have to switch to classes to get types. The setmetatable pattern for objects is followed: the methods of the __index table are found on the object, and calls to them are checked.

src/account.wowluawowlua
local Account = {}Account.__index = Account function Account:deposit(amount: number)    self.balance += amountend function Account.new(balance: number)    return setmetatable({ balance = balance }, Account)end local acc = Account.new(100)acc:deposit(5)

acc:deposit("5 gold") is E2001, and setmetatable(o, { __index = Account }) works the same. In function Account:deposit, self has the type of Account. The object has the methods the file defines on Account, also those after Account.new, so in strict mode a misspelled acc:depositt(5) is E2003. A class checks more: missing fields and visibility, and a misspelled method name in every mode.

main · 4b0211csource on GitHub ↗