Area: glua_check (expected to affect the language server too; the repro and diagnostics below are from glua_check)
Summary
if a.x ~= nil then ... end, where a is an untyped parameter, over-narrows a.x in the true branch to never instead of merely removing nil. The value is not impossible - a type(a.x) == "number" check recovers a real number - so never is the wrong type.
It surfaces where the narrowed value flows into a call that rejects never: math.max reports expected integer but found never, naming the bad type directly. A plain typed sink silently accepts it (since never is assignable to everything), so math.max is only the messenger that makes the over-narrowing visible. It is a warning, so it fails glua_check --warnings-as-errors with exit 1.
Minimal repro
Repro: bug6-neq-nil-overnarrows-never.zip
Standalone - no workspace.library and no GLua annotations. .luarc.json is only {"runtime":{"version":"LuaJIT"}}.
local function base(a)
if a.x ~= nil then math.max(0, a.x) end -- warning: expected `integer` but found `never`
end
glua_check --warnings-as-errors . on the repro reports (line 31 is the base line in repro.lua, below the header comment):
warning: expected `integer` but found `never`. [param-type-mismatch]
--> repro.lua:31:36
a is untyped, so a.x is any. Removing nil from any should leave a non-nil any, not never.
Controls that isolate it
All in the repro file:
type(a.x) == "number" instead of ~= nil - clean. The value really is a number; a type() check reconstructs it, which is only possible because it is not actually never.
if a.x then (truthiness) instead of ~= nil - clean. So this is specific to ~= nil, not to nil-narrowing in general.
- A declared nilable field (
---@field x number? on a declared class) - clean. So it is specific to an untyped container.
- The real-site shape - an
isnumber-style TypeGuard<number> inside the ~= nil guard - still fires (second warning in the repro). A TypeGuard intersects with the current type, and never & number = never, so a runtime isnumber check cannot recover the value. This is why the real fix had to be a --[[@as number]] cast rather than a guard.
Real-world impact
TARDIS sv_tardis.lua validates a request table:
if args.base_light_brightness ~= nil then
if not isnumber(args.base_light_brightness) then return { ok = false, error = "..." } end
int:SetCustomBaseLightBrightness(math.max(0, args.base_light_brightness --[[@as number]]))
end
args is untyped, so the outer ~= nil over-narrows the field to never; the isnumber guard cannot bring it back (never & number = never); and math.max then reports found never, failing the build. We carry the --[[@as number]] cast purely to satisfy it.
Expected
Inside if a.x ~= nil, a.x is a non-nil any (not never), and math.max(0, a.x) type-checks - matching the type()-guard and declared-field forms, which already are clean.
Environment
glua_check 1.2.0
- annotations
gluals-annotations-prerelease @ aae7341 (not needed for the repro)
- Windows 11
Disclosure: this report was researched and written by Claude Code working in my repositories. Every claim in it was reproduced against a real workspace on 1.2.0 rather than asserted, and all four controls were run before filing. isnumber is replaced in the repro by a local ---@return TypeGuard<number> stand-in so it needs no GMod annotations; it behaves identically. The a / args reduction keeps the real site's structure: an untyped request table, an outer ~= nil guard, a numeric TypeGuard, then math.max.
Area:
glua_check(expected to affect the language server too; the repro and diagnostics below are fromglua_check)Summary
if a.x ~= nil then ... end, whereais an untyped parameter, over-narrowsa.xin the true branch toneverinstead of merely removingnil. The value is not impossible - atype(a.x) == "number"check recovers a realnumber- soneveris the wrong type.It surfaces where the narrowed value flows into a call that rejects
never:math.maxreportsexpected integer but found never, naming the bad type directly. A plain typed sink silently accepts it (sinceneveris assignable to everything), somath.maxis only the messenger that makes the over-narrowing visible. It is a warning, so it failsglua_check --warnings-as-errorswith exit 1.Minimal repro
Repro: bug6-neq-nil-overnarrows-never.zip
Standalone - no
workspace.libraryand no GLua annotations..luarc.jsonis only{"runtime":{"version":"LuaJIT"}}.glua_check --warnings-as-errors .on the repro reports (line 31 is thebaseline inrepro.lua, below the header comment):ais untyped, soa.xisany. Removingnilfromanyshould leave a non-nilany, notnever.Controls that isolate it
All in the repro file:
type(a.x) == "number"instead of~= nil- clean. The value really is anumber; atype()check reconstructs it, which is only possible because it is not actuallynever.if a.x then(truthiness) instead of~= nil- clean. So this is specific to~= nil, not to nil-narrowing in general.---@field x number?on a declared class) - clean. So it is specific to an untyped container.isnumber-styleTypeGuard<number>inside the~= nilguard - still fires (second warning in the repro). ATypeGuardintersects with the current type, andnever & number = never, so a runtimeisnumbercheck cannot recover the value. This is why the real fix had to be a--[[@as number]]cast rather than a guard.Real-world impact
TARDIS
sv_tardis.luavalidates a request table:argsis untyped, so the outer~= nilover-narrows the field tonever; theisnumberguard cannot bring it back (never & number = never); andmath.maxthen reportsfound never, failing the build. We carry the--[[@as number]]cast purely to satisfy it.Expected
Inside
if a.x ~= nil,a.xis a non-nilany(notnever), andmath.max(0, a.x)type-checks - matching thetype()-guard and declared-field forms, which already are clean.Environment
glua_check1.2.0gluals-annotations-prerelease@aae7341(not needed for the repro)Disclosure: this report was researched and written by Claude Code working in my repositories. Every claim in it was reproduced against a real workspace on 1.2.0 rather than asserted, and all four controls were run before filing.
isnumberis replaced in the repro by a local---@return TypeGuard<number>stand-in so it needs no GMod annotations; it behaves identically. Thea/argsreduction keeps the real site's structure: an untyped request table, an outer~= nilguard, a numericTypeGuard, thenmath.max.