Skip to content

[Bug]: An empty {} field write is rejected against its own declared type when two closures share a fun(self: T) callback slot #80

Description

@AmyJeanes

Area: glua_check (expected to affect the language server too; the repro and diagnostics below are from glua_check)


Summary

An empty {} assigned to a class field is rejected against the field's own declared container type, when a second closure - bound to the same fun(self: T) callback slot - writes self.field[k] = nil to that field. The [k] = nil write makes the element type nilable, and that re-inference reaches back into the first closure and rejects its empty {}. An empty table is a valid table<any, element>, so nothing should be reported.

This looks like the now-fixed #50 - same "empty {} re-inferred from a later write and rejected against its declared type" symptom, and the message reads the same. But #50's own repro cases are clean on 1.2.0, so this is not that. The surviving trigger here is structural: it needs the receiver to be inferred from a fun(self: T) callback slot, and the two writes to live in two separate closures bound to that slot. Collapse either ingredient and it clears. So it is a cross-closure field-type unification through the shared inferred self, not #50's single-scope re-inference.

Minimal repro

Repro: bug1-empty-table-callback-slot.zip

Standalone - no workspace.library and no GLua annotations. .luarc.json is only {"runtime":{"version":"LuaJIT"}}.

---@class element
---@class holder
---@field map table<any, element>

---@param f fun(self: holder)
local function hook(f) end

-- FIRES: two closures through the callback slot.
hook(function(self) self.map = {} end)          -- rejected
hook(function(self) self.map["k"] = nil end)    -- ...makes the element type nilable

glua_check --warnings-as-errors . on the repro reports exactly one diagnostic (line 28 is the first hook(...) line in repro.lua, below the header comment):

hint: Cannot assign `{ k = nil }` to `table<any,element>`.  [assign-type-mismatch]
  --> repro.lua:28:21

The {} on the first hook line is a legal empty table<any, element>. The self.map["k"] = nil in the second, unrelated closure is what makes it fire - remove that line and the diagnostic goes away.

Two controls that isolate it

Both are in the repro file, and both are clean - each drops one ingredient:

  • Both writes in ONE closure body (hook(function(self) self.map = {}; self.map["k"] = nil end)) - clean. So it is not the two writes per se; it is the two writes living in separate closures.
  • Two functions, but self declared via ---@param self holder instead of the callback slot - clean. So the callback-slot inference of self is required; a declared receiver is fine.

And, to be explicit about the relationship to #50: #50's own cases A and D are clean on 1.2.0 (I re-ran them). The fix landed; this is an adjacent shape it does not cover.

Real-world impact

Hint-level, so it does not fail a build. But it forced a spurious cast into real code: TARDIS cl_idlesound.lua declares ---@field idlesounds table<any, doors_managed_sound>, seeds it with self.idlesounds = {} in its Initialize and ExteriorChanged hooks, and clears entries with self.idlesounds[k] = nil in its Think hook - every hook an ENT:AddHook(name, id, function(self) ... end), i.e. the callback slot. On 1.2.0 both = {} seeds are rejected, and we carry a --[[@as table<any, doors_managed_sound>]] on each purely to silence it.

Expected

The empty {} is accepted as a valid table<any, element> and nothing is reported - matching the single-body and ---@param-declared forms, which already are.

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 the two controls and the #50 re-check were run before filing. The ---@class element / ---@class holder names are placeholders standing in for a real content class and its owning entity; the reduction keeps the exact structure of the real site.

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