Skip to content

Resolve unbound class generics to any - #3442

Open
RomanSpector wants to merge 2 commits into
LuaLS:masterfrom
RomanSpector:fix/unbound-class-generics
Open

Resolve unbound class generics to any#3442
RomanSpector wants to merge 2 commits into
LuaLS:masterfrom
RomanSpector:fix/unbound-class-generics

Conversation

@RomanSpector

Copy link
Copy Markdown
Contributor

A generic class can be referenced without arguments. Its fields kept the parameter itself as their type, displayed as <T> — a type no value can ever have:

---@class Repository<T>
---@field Items T[]
---@field Get fun(self: Repository, index: integer): T

---@type Repository
local repo

local items = repo.Items      -- <T>
local first = repo:Get(1)     -- <T>

Why this matters beyond cosmetics

It effectively rules out adding a parameter to an existing, widely used class. Consider a store that holds elements of many kinds and hands them to code that knows exactly what it works with:

---@class Element
---@field Id integer
---@field Value any

---@class Store
---@field ById table<integer, Element>

---@param element Element
local function register(element) end

Turning Element into Element<T> is exactly the right move for the consumers — a toggle wants Element<boolean>, a slider wants Element<number>. But every place that legitimately does not care about the parameter — the store, the registry, the traversal helpers, the register signature — suddenly deals in <T>. In a codebase of any size that is dozens of mentions, and the only escape is writing Element<any> in each of them: noise that says nothing and has to be maintained forever.

The alternative workaround is worse: declaring a second, "typed" subclass purely to carry the parameter, duplicating a class for no reason other than to satisfy the checker.

What this changes

Unbound parameters resolve to any, so those places behave exactly as before the class gained a parameter, while explicit arguments still win:

---@type Repository<string>
local named
local values = named.Items    -- string[]

This holds for methods as well, not only fields, and matches what writing Repository<any> by hand already does today.

Why any and not unknown

unknown reads as "inference failed" and is reported by no-unknown; here nothing failed — the parameter was simply never bound, and the value genuinely can be anything. any also keeps the promise that adding a parameter to a class does not change how existing code is checked.

Worth noting for symmetry: an unbound parameter of a generic function still yields unknown, so the two are not aligned. If you would rather have both behave the same, say which way and I will follow.

Other uses this opens up

Gradual typing of an existing hierarchy — add the parameter to a base class today, annotate consumers one by one, and untouched code keeps working. Container-like classes (Queue<T>, Cache<T>, Result<T>) can be referenced generically in plumbing and precisely at the point of use.

A generic class can be referenced without arguments — `---@type Box` instead of
`Box<string>`. Its fields kept the parameter itself as their type, shown as `<T>`: a type
no value can ever have. Every mention of such a class without arguments then became
unusable, which in practice rules out making an existing widely-used class generic.

Unbound parameters now resolve to `any`, so those places behave exactly as before the
class gained a parameter. Where arguments are given they still win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant