Nil Safety and Narrowing
Most runtime crashes in WoW addons are nil errors. A function returns nil on failure, a field is unset, a table lookup misses — and your addon throws attempt to index a nil value in the middle of a raid. wowlua-ls is built to catch these at edit time.
The need-check-nil diagnostic
The need-check-nil diagnostic warns when you access a field or call a method on a value that might be nil:
---@param name string?
function greet(name)
print(name:upper()) -- warning: need-check-nil
endThis diagnostic is off by default because it can be noisy on unannotated codebases. Enable it in .wowluarc.json when you're ready:
{
"diagnostics": {
"enable": ["need-check-nil"]
}
}Enable it early
need-check-nil is the single most valuable diagnostic for catching real bugs. Enable it as soon as your core types are annotated. The false positives it produces are usually signals that your annotations are incomplete — fixing them improves your type coverage everywhere.
Narrowing
Narrowing is how the LS tracks that a value is no longer nil after you've checked it. wowlua-ls understands every common Lua guard pattern:
if x then / if x ~= nil then
---@param name string?
function greet(name)
if name then
print(name:upper()) -- OK, narrowed to string
end
if name ~= nil then
print(name:upper()) -- also works
end
endEarly exit (if not x then return end)
Guard at the top, use freely below:
---@param player Player?
function processPlayer(player)
if not player then return end
-- player is non-nil for the rest of the function
player:Update()
print(player.name)
endAll early-exit forms work: return, return value, error(), break (in loops).
assert()
---@param frame Frame?
function setupFrame(frame)
assert(frame)
frame:SetPoint("CENTER") -- narrowed
end
-- Also works with assert(x, message)
assert(frame, "frame not created")
frame:Show() -- narrowedtype() guards
---@param value string|number|table
function process(value)
if type(value) == "string" then
print(value:upper()) -- narrowed to string
elseif type(value) == "number" then
print(value + 1) -- narrowed to number
else
-- narrowed to table
end
endtype() guards also work on field chains:
---@param obj {data: string|table}
function handle(obj)
if type(obj.data) == "table" then
obj.data[1] -- narrowed to table
end
endwhile post-conditions
After a while not x do loop, the LS knows x is non-nil (since the loop only exits when the condition is false):
---@type string?
local result = nil
while not result do
result = tryFetch()
end
-- result: string (narrowed)Compound and/or
---@param a string?
---@param b string?
function combine(a, b)
if a and b then
print(a .. b) -- both narrowed
end
local name = a or "default" -- name: string (never nil)
endCorrelated multi-return narrowing
This is where wowlua-ls really shines. When a function returns multiple values that are correlated (either all set or all nil), guarding one narrows all of them:
---@return (string name, number level) | (nil, nil)
function getPlayer(id) ... end
local name, level = getPlayer(id)
-- name: string | nil, level: number | nil
if name then
-- Both narrowed: name is string, level is number
print(name .. " is level " .. level)
endYou checked name, but level also narrowed to non-nil. This works because the tuple-union @return tells the LS that name and level always come together — if one is non-nil, the other must be too.
This eliminates a huge class of false positives. Without correlated narrowing, you'd either need to check every return value individually or suppress the warnings.
How it works
The tuple-union return syntax (A, B) | (C, D) declares that the function returns one of those specific combinations. When you narrow any position, the LS filters the possible cases and derives the types for all other positions from the surviving cases.
---@return (true ok, number value) | (false, string error)
function parse(input) ... end
local ok, result = parse(input)
if ok then
-- ok is true, so only case 1 survives → result is number
print(result + 1)
else
-- ok is false, so only case 2 survives → result is string
print("Error: " .. result)
endInferred correlations
You don't always need to write tuple-union returns. If the LS can see your function's return statements and they follow an all-set-or-all-nil pattern, it infers the correlation automatically:
-- No annotations needed
local function findItem(name)
local item = lookup(name)
if not item then
return nil, nil
end
return item.id, item.count
end
local id, count = findItem("sword")
if id then
print(count + 1) -- count also narrowed (inferred correlation)
endThis inference is on by default (inference.correlated_return_overloads: true). It requires:
- No
@returnannotations on the function - At least 2 return statements with matching arity
- Every return is either all-nil or has no nil positions
Correlated fields (@correlated)
Fields on a class can be correlated too. Declare them with @correlated:
---@class AuctionState
---@correlated itemString, duration, buyout
---@field itemString string?
---@field duration number?
---@field buyout number?
---@field cache string?Now checking one field narrows the whole group:
---@param state AuctionState
function process(state)
if state.itemString then
print(state.duration * 2) -- narrowed, no warning
print(state.buyout + 1) -- narrowed, no warning
print(state.cache:upper()) -- still warns (not in the group)
end
endMultiple independent groups are supported:
---@class TradeState
---@correlated handler, money
---@correlated pendingItem, pendingCount
---@field handler function?
---@field money number?
---@field pendingItem string?
---@field pendingCount number?Correlated groups are inherited by child classes.
Correlated locals (inferred)
When multiple locals are assigned in every branch of an if/elseif chain (without else), the LS infers they're correlated:
local tradeType = nil ---@type string?
local money = nil ---@type number?
if condition1 then
tradeType = "buy"
money = 100
elseif condition2 then
tradeType = "sell"
money = 200
end
if not tradeType then return end
-- money is also narrowed to numberNo annotation needed — the LS detects the pattern automatically.
Lateinit fields (T!)
Some fields are conceptually non-nil but may be nil at certain points in the object's lifecycle (like object pool acquire/release cycles). The ! suffix declares a "lateinit" field:
---@class PooledTooltip
---@field _frame GameTooltip!
---@field _anchor Frame!Lateinit fields:
- Allow
nilassignment withoutfield-type-mismatch - Don't require nil guards before access (no
need-check-nil) - Show as
T!in hover so you know the contract
self._frame = nil -- OK (allowed for lateinit)
self._frame:Show() -- OK (no nil check needed)This is similar to Swift's T! (implicitly unwrapped optionals) or Kotlin's lateinit.
x = x or y coalesce narrowing
The common x = x or default idiom is understood:
---@param name string?
function greet(name)
name = name or "stranger"
-- name is now string (never nil)
print(name:upper())
endThis extends to correlated values: if y is later narrowed to non-nil, x (which was assigned via x or y) is also narrowed.
