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:
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:
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 = MChained metatables
Instance → Child → Base chains work:
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:
---@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() -- resolvedStatement form
setmetatable doesn't need to be in an assignment - it mutates the table in place:
local t = {}
setmetatable(t, { __index = SomeClass })
t:method() -- resolves through __indexInstance field priority
Direct fields on the table always win over __index fields:
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:
local Counter = setmetatable({ n = 0 }, {
__call = function(self)
self.n = self.n + 1
return self.n
end
})
local val = Counter() -- val: numberOperator metamethods
Arithmetic operators check for metamethods and use their return types:
---@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 -- numberSupported 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:
local mt = { __index = { z = 3 } }
local obj = setmetatable({}, mt)
local m = getmetatable(obj) -- m has mt's typeClass 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:
---@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() -- resolvedThe LS tries three sources for the class name (in priority order):
__indexas a direct table withclass_name- The metatable itself having
class_name __indexas 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:
-- 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
endThe 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:
| Situation | Recommendation |
|---|---|
Simple mt.__index = mt | Inference handles it |
Factory returning setmetatable({}, self) | Add @class ClassName on the class table and @return ClassName on the factory |
| Complex multi-file class | Use @class + @field |
Dynamic __index function | Inference may not follow - use @class |
__index from another file | Needs @class on the target for cross-file |
