lloyAlloy on GitHub

← Every RFC

Statics: the static keyword for members of a type

SyntaxNoted for laterThe file on GitHub →

Summary

A type gains members that belong to it and not to a value, and one word marks every one of them: static. A struct body takes static fields and static const constants, read as Wallet.made and Wallet.MAX; an impl takes static function, called as Wallet.new(), Vector3.zero(), Status.describe(s). A function in an impl with no self and no static is an error with a fix, and self inside a static function is an error. This proposal gives the syntax, the emit, the checks, and the editor's answers, and settles the questions the last probe rounds raised for static functions.

Motivation

A type often owns a value that no instance owns: a counter of the instances made, a registry, a cap, a default. Today that value lives beside the type as a const or a local, or in a namespace, and the reader has to know the convention to find it:

local wallets_made = 0             -- belongs to Wallet, lives nowhere
export const MAX_COINS = 1000      -- same

export struct Wallet as
    coins: number = 0
end

impl Wallet as
    function new(coins: number): Wallet
        wallets_made += 1
        return new Wallet { coins = math.min(coins, MAX_COINS) }
    end
end

A second file that imports Wallet cannot reach wallets_made at all, and reaches MAX_COINS only through a second import. In a class language the reader writes Wallet.made and Wallet.MAX, and the type is the one place to look.

Static functions already exist, and today the absence of self is the only mark. A reader scanning an impl has to read every parameter list to know which functions belong to the type; a function new(coins: number) and a function balance(self) look the same at a glance, and a self typed by mistake turns a static into a method with no report. The rules for statics also grew one fix at a time through the probe rounds (unit enums, generic impls, private, namespace paths). One word on every static member, data or function, lets the reader and the compiler know at the declaration.

Design

What a user writes

export struct Wallet as
    coins: number = 0                 -- an instance field
    static made: number = 0           -- a static field, on the type
    static const MAX: number = 1000   -- a static constant
    private static registry: { Wallet } = {}
end

impl Wallet as
    static function new(coins: number): Wallet   -- a static function
        Wallet.made += 1
        local w = new Wallet { coins = math.min(coins, Wallet.MAX) }
        table.insert(Wallet.registry, w)
        return w
    end

    function balance(self): number               -- a method
        return self.coins
    end
end

print(Wallet.made, Wallet.MAX)

What it means

A static member lives on the type's table, Wallet.made, and never on a value. self.made inside a method is an error that names the fix:

error(3.6): StructError: `made` is a static of `Wallet`; write
`Wallet.made`

new Wallet { made = 1 } is a ConstructorError for the same reason: a static is not a field of a value. @derive(Debug), @derive(Eq), and Serialize read instance fields only; a static is not part of a value's shape.

A generic struct's static is one slot for every instantiation: Box.made counts every Box<T>. A static may not name a type parameter, because there is no T on the type's table; static last: T reports a static has one slot for every `T`; give it a type that names none.

Inside a namespace, a static is reached by the path the source writes: Geo.Vec.origin. Through import { Wallet } or import * as M, the static follows the type: Wallet.MAX, M.Wallet.MAX. An export struct exports its statics with it; a private static stays private across files as a private field does.

The emit

The struct body's lines stay the struct's lines. Today a struct emits its class table on the struct line and blanks the field lines; a static's line writes the slot instead, so the emit stays on the source's own lines:

local Wallet = {} Wallet.__index = Wallet ...   -- the struct line
                                                 -- coins: blank
Wallet.made = 0                                  -- static made
Wallet.MAX = 1000                                -- static const MAX
Wallet.registry = {}                             -- private static

The check artifact types the class table with the statics and keeps them out of the value type:

type Wallet = { coins: number, balance: (self: Wallet) -> number }
local Wallet: { made: number, read MAX: number, new: ..., ... } = ...

A static const is a read member, so a write reports in the checker as well as in the compiler. A private static goes in the private view (Wallet__private) the way a private field does, and the private_access lint names an outside read or write.

Static functions

A static function in an impl is a static of the type; a function with self is a method. The compiler reads the word, not the parameter list, and reports the two ways they can disagree:

error(3.6): StructError: `new` takes no `self`; a function of the type
is written `static function new`
error(3.6): StructError: `static function balance` takes `self`; a
method is written `function balance(self)`

The first report carries the quickfix Add \static\`, and alloy flux --fix` applies it project wide, which is the migration for every impl written before this proposal. The rules the compiler follows for a static function:

What the editor answers

Errors, in the compiler's words

Drawbacks

static becomes a reserved word in a struct body. Luau does not reserve it, so local static = 1 stays valid and a field named static needs no change: the word is contextual, read only at the start of a field line, the way read and write are.

A static field is shared state on a type. The proposal makes that visible in the syntax and reachable across files, which is the point, and also a foot-gun for code that treated a file local as private by accident. private static is the answer for that code.

Every impl written so far has static functions without the word, so every project gets a report per static on the day this lands. The report is one quickfix, alloy flux --fix applies it to a whole project in one run, and the emit does not change, so the migration is a diff of one word per static and nothing at runtime. The upstream watch matters here: Luau's class proposals (240, 242, 247) spell a class static with the same word, so an Alloy impl reads the way an upstream class will.

A project that wrote Wallet.made = 0 after the struct by hand keeps working: the emit is the same assignment. It gains a type and a hover by moving the line into the body.

Alternatives

Prior Art