Skip to content

Metatable Inference ​

WoW addon OOP is built on metatables. wowlua-ls understands setmetatable, __index chains, __call, operator metamethods, and getmetatable - often without any annotations at all.

setmetatable + __index ​

The most common WoW addon pattern:

lua
local MyClass = {}
MyClass.__index = MyClass

function MyClass:GetName()
    return self.name
end

local obj = setmetatable({ name = "test" }, MyClass)
obj:GetName() -- found on MyClass via __index
obj.name      -- string (direct field, takes priority)

The LS traces the metatable chain: obj → metatable MyClass → __index is MyClass → GetName is found.

Self-referential metatables ​

The mt.__index = mt pattern is the most common in WoW addons, and it's fully supported:

lua
local M = {}
M.__index = M

function M.new()
    return setmetatable({}, M)
end

function M:method()
    return "hello"
end

local inst = M.new()
inst:method() -- resolves through __index = M

Chained metatables ​

Instance → Child → Base chains work:

lua
local Base = {}
Base.__index = Base
function Base:baseMethod() return "base" end

local Child = setmetatable({}, Base)
Child.__index = Child
function Child:childMethod() return "child" end

local inst = setmetatable({}, Child)
inst:childMethod() -- found on Child
inst:baseMethod()  -- found on Base (via chain)

Factory functions ​

A factory that returns setmetatable({}, self) propagates the class type:

lua
---@class Widget
local Widget = {}
Widget.__index = Widget

---@return Widget
function Widget:New()
    return setmetatable({}, self)
end

function Widget:Show()
    print("showing")
end

local w = Widget:New()
w:Show() -- resolved

Statement form ​

setmetatable doesn't need to be in an assignment - it mutates the table in place:

lua
local t = {}
setmetatable(t, { __index = SomeClass })
t:method() -- resolves through __index

Instance field priority ​

Direct fields on the table always win over __index fields:

lua
local M = {}
M.__index = M
M.x = 10

local obj = setmetatable({ x = 20 }, M)
obj.x -- 20 (direct field), not 10 (from __index)

__call metamethod ​

Tables with __call become callable:

lua
local Counter = setmetatable({ n = 0 }, {
    __call = function(self)
        self.n = self.n + 1
        return self.n
    end
})

local val = Counter() -- val: number

Operator metamethods ​

Arithmetic operators check for metamethods and use their return types:

lua
---@class Vec2
---@field x number
---@field y number
local Vec2 = {}
Vec2.__index = Vec2

---@param a Vec2
---@param b Vec2
---@return Vec2
Vec2.__add = function(a, b)
    return setmetatable({ x = a.x + b.x, y = a.y + b.y }, Vec2)
end

---@type Vec2, Vec2
local a, b
local c = a + b -- c: Vec2
c.x             -- number

Supported metamethods: __add (+), __sub (-), __mul (*), __div (/), __mod (%), __pow (^), __concat (..), __unm (unary -), __len (#).

The LS checks the table's metatable first, then the table itself (for @class tables with metamethods as direct fields).

getmetatable() ​

Returns the raw metatable:

lua
local mt = { __index = { z = 3 } }
local obj = setmetatable({}, mt)
local m = getmetatable(obj) -- m has mt's type

Class name propagation ​

When a metatable's __index points to a @class table, the class name propagates to instances. This is how setmetatable({}, MyClass) creates instances of MyClass even without a @return MyClass annotation:

lua
---@class Tooltip
local Tooltip = {}
Tooltip.__index = Tooltip

function Tooltip:Show() end

local inst = setmetatable({}, Tooltip)
-- inst's type is Tooltip (class name propagated from __index)
inst:Show() -- resolved

The LS tries three sources for the class name (in priority order):

  1. __index as a direct table with class_name
  2. The metatable itself having class_name
  3. __index as a function that returns access to a class-typed table

Cross-file support ​

Metatable inference works across files when the __index target is a @class table (since those are globally registered). For unannotated metatables in other files, add a @class annotation to get cross-file resolution.

The fields a constructor sets on the instance it builds with setmetatable (the entries of the table it passes, and the fields it writes on the instance afterwards) are fields of the instance's class, in every file:

lua
-- Holder.lua
local _, ns = ...

---@class Holder
local Holder = {}
Holder.__index = Holder

function ns.NewHolder()
    local h = setmetatable({ size = 1 }, Holder)
    h.count = 0
    return h
end

-- Other.lua
---@param holder Holder
local function Describe(holder)
    print(holder.count, holder.size) -- number, number
end

The instance's class is the one its metatable names (setmetatable({}, Holder), setmetatable({}, { __index = Holder }), setmetatable({}, self) in a method of Holder, or a local metatable whose __index is Holder), or the class a ---@type or ---@class annotation on the local gives it.

When to annotate vs. rely on inference ​

The LS resolves a lot from metatables alone, but there are cases where annotations help:

SituationRecommendation
Simple mt.__index = mtInference handles it
Factory returning setmetatable({}, self)Add @class ClassName on the class table and @return ClassName on the factory
Complex multi-file classUse @class + @field
Dynamic __index functionInference may not follow - use @class
__index from another fileNeeds @class on the target for cross-file