Skip to content

[Bug]: if a.x ~= nil over-narrows an untyped container's field to never #85

Description

@AmyJeanes

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions