Add struct keyword for ergonomic OOP over records - #1161
Conversation
struct is a thin layer over record that removes the metatable boilerplate typically needed for object-oriented Lua: - auto-generated .new constructor (opts-table based, nominal: table literals are rejected where a struct instance is expected) - optional :init() hook, called explicitly as X.init(self) - auto-set __index so method syntax (x:method()) works - single inheritance via 'from' (validated: parent must be a struct from the same module, alias parents resolve to runtime names, child <: parent subtyping with transitive ancestor tracking) - field default values (x: number = 0), typechecked against the field type at the declaration point; falsy-safe codegen (if opts.x == nil then ... end), emitted in declaration order - static ... end block for type-level fields: excluded from .new opts (passing them is a type error), initializers emitted once on the type, inherited by child structs - clear rejections: user-declared X.new, generic structs, nested structs, static blocks / 'from' in records, incompatible field overrides Includes global struct support, 41 new specs, and documentation in docs/src/structs.md.
… init Syntax: `local struct Circle : Shape` (whitespace optional). The previous '(Parent)' and 'from' forms are gone; records/interfaces using ':Parent' get a clear syntax error. Runtime model is now dispatch-free: - inherited methods are flattened at declaration time via explicit `X.m = P.m` copies: an instance method call is a single table lookup, struct tables carry no metatable chain; a child method defined after the declaration simply overwrites its copy, so overrides always take effect (last definition wins) - .new runs the init chain explicitly, root parent first, one guarded call per ancestor, each init exactly once (`init` is never flattened, so plain lookups are unambiguous) - default values (own + inherited) are merged by the typechecker into a single map: one `if opts.x == nil then self.x = ... end` per field, child overrides win, no redundant copies Also makes default-value typecheck errors deterministic (iterate field_order instead of pairs) and simplifies emit_struct_runtime.
…faults once - struct_init_chain is now pre-filtered at check time to ancestors that declare their own init; codegen emits unconditional `Anc.init(self)` calls — no runtime guards, no dispatch - structs without any init emit no init call at all - `init` is no longer inherited as a field (it is a lifecycle hook invoked via the chain only); this also keeps the chain filter accurate for middle ancestors without their own init - own default-value expressions are typechecked once, before the inheritance merge (inherited defaults were validated at their declaration site); removes quadratic re-checking down a chain - fixes nil-safety of `is` on fields["init"] lookups
…skip finalize - a struct body field named 'new' now fails with a clear "'X.new' is reserved" message instead of a confusing "not a function" at the call site - `local type P = SomeStruct` (alias) no longer re-runs struct finalization: the aliased struct was finalized at its own declaration, and re-running it would misfire on the generated .new - adds a 7-case acceptance battery to the specs: reserved/init data fields, static shadowing, metamethods in struct bodies, array interfaces + inheritance, cross-module export/instantiation, and cross-module parent rejection
A struct can now extend a struct returned directly by a required
module, assigned to a top-level local:
local Animal = require("animal") -- module returns the struct
local struct Dog:Animal ... end
The local is guaranteed to hold the struct's runtime table (module
files execute to completion before require returns), so the emitted
method copies and init-chain calls are sound.
Gated restrictions, each rejected with a clear error:
- the parent must be a single-name top-level require local (tracked
via node_is_require_call in local_declaration): field paths, bare
globals and function params have no runtime presence guarantee
- the parent must not inherit init from its own ancestors (their
tables are not visible outside the parent's module)
- the parent's defaults must be literals: computed defaults may
reference the parent module's locals and are not relocatable
- structs described by .d.tl declaration files cannot be extended
…ning Adds a 'How it works: the generated code' section showing the exact Lua emitted for constructors, default application, the init chain (unconditional direct calls, ancestors without init simply absent) and method flattening (X.m = P.m, overrides as later assignments), with a per-feature runtime cost table. Aimed at reviewers: everything inheritance-related is resolved at compile time.
'type Inner = struct ... end' inside a record body slipped past the reader-level ban on nested structs and produced a half-initialized struct type: is_struct set (so table literals were rejected) but no finalization, no synthesized .new and no emitted runtime table, yielding a confusing "invalid key 'new'" at use sites. Now rejected at parse time with the same message as the block form. Structs in function bodies and top-level 'local type P = struct' declarations were verified to work fully and are covered by new specs.
Insertions done earlier in the branch shifted some surrounding lines by one space, producing ~150 whitespace-only churn lines in the diff. This realigns every touched region byte-for-byte with upstream indentation (including upstream's own quirks, e.g. the 16-space add_warning line in visitors.tl and the 6-space returns in ast.tl's parse_type chain) so the diff now contains zero reindented lines.
Method flattening and init chaining capture a snapshot of the parent at each child's declaration time: anything the parent declares afterwards silently never reaches the children. Three failure modes were possible: - a parent init declared after a child was silently omitted from the child's generated .new (lost initialization, no diagnostic); - a parent method declared after a child produced a confusing "invalid key" error at the call site instead of at its cause; - a method whose *type* was declared in the parent body but whose implementation came after the child passed the type checker and crashed at runtime (flattened copy was nil). RecordType gains has_struct_children, set when any struct extends it; the record_function visitor then rejects new declarations on the frozen parent with a clear "declare parent methods before child structs" error. Adds four specs covering all three failure modes plus the legitimate no-children case.
|
Hard disagree with adding this in as a keyword, you can easily make this with just a macro. For the design itself, having the user define |
|
I also disagree with this approach. While I am sure the existing record syntax leaves some to be desired, Teal specifically went out of its way to not have OOP built in because every library does it differently. That is why you can only extend interfaces which don't exist in code anyway. Adding syntax to do OOP opens the question of "What method of OOP is the blessed way?" again. |
|
"new" and "init" method is reserved only for structs. No conflict with record or any other data structure. "init" is without any parameters, because it only prepare data that user provide in new. It is has no any reason to call parent "init" by itself, chain of parent "init"s calls automatically. It also does not effect any other library, because this syntax only for new data structure "struct". |
…-inherit guards Four audit findings: - data fields named 'init' are now rejected like 'new' (the name is reserved for the lifecycle hook); a data-init previously vanished silently when inherited by a child struct - inherited statics are copied by reference at the child's declaration (Child.f = Parent.f, same lifecycle as flattened methods): struct instances stored in statics are shared by the whole hierarchy, and uninitialized parent statics assigned before the child are inherited correctly; previously they were merged by type only, so the type said string while the runtime read nil. Static-static overrides via the child's static block work; child-static vs parent-instance is still a conflict - casting a table literal to a struct type is rejected (the result would lack the metatable wiring and crash on first method call); casting variables remains the interop escape hatch - 'struct A:A' is rejected with "cannot extend itself" (it previously typechecked and generated a struct table set as its own ancestor) Adds specs for all four (75 struct specs total, 2001 in the suite).
|
"struct" is simpliest implementation of OOP without complex virtualization and dispatch. It covers most common and useful scenarios. If community ever decide to implement complex OOP, it could be "class" or anything else. |
Add
structkeyword for ergonomic OOP over recordsReviewing this PR
Changes were made in 10 files — everything else is regenerated
build output:
teal/reader.tl,teal/ast.tl,teal/block.tl,teal/types.tl,teal/check/{context,relations,visitors}.tl,teal/gen/lua_generator.tl,docs/src/structs.md,docs/src/SUMMARY.mdspec/lang/declaration/struct_spec.luateal/*.lua,teal.luaandtl.luaare self-build artifacts (Tealcompiles its own
.tlsources; the compiled output is committed perrepo convention). The blank-line runs there are the generator's
newline preservation: comments and type declarations in
.tlproduceno code, and the output is padded 1:1 so line numbers stay aligned
between
.tland.lua(this is what makestl runerrors point atthe right source lines). Reviewers can skip those files entirely.
Summary
This PR introduces a
structdeclaration: a thin layer overrecordthatremoves the metatable boilerplate typically needed for object-oriented
Lua, while keeping Teal's compile-time philosophy intact. It is additive
at the language level — no previously-valid
recordprogram changesbehavior — but it is built into record's machinery rather than bolted
on beside it (see Compatibility for exactly what that means).
Two design decisions define the whole feature:
(
struct Dog:Animal); there is no multiple inheritance, no mixins,no diamond problem — ever. This keeps both the type checker story and
the generated code linear and predictable.
walks, no runtime resolution: inheritance is fully resolved at
compile time, and a method call on an instance is a single table
lookup. Details and generated code below.
Motivation
The most common Teal OOP pattern today is hand-written metatable
plumbing, repeated for every type:
structremoves this boilerplate, and builds the pieces the hand-writtenpattern cannot express on top of it:
.newwhose parameter is a typed record of thestruct's instance fields (unknown keys are type errors, statics and
methods are excluded);
initlifecycle hook with a cascade:.newof a child runsevery
initin the hierarchy, root parent first, each exactly once —something each hand-rolled constructor would have to re-implement
(and usually doesn't);
static ... endblock for type-level fields (constants, sharedstate): they get a proper declaration site, are set once at load
time, are readable through instances via
__index, and.newrejects them in opts. Today this is ad-hoc discipline — assignments
scattered after the type definition, invisible to the type checker;
default-value merging (child overrides win). Children also inherit
the parent's static fields: inherited statics are copied by
reference at the child's declaration (
Dog.config = Animal.config),so struct instances stored in statics are shared by the whole
hierarchy — the common configuration/singleton/pool pattern works
with zero dispatch. The hand-written pattern only wires instances
(
setmetatable({}, { __index = Animal })) — for the class tableitself to see the parent's members you'd need a second metatable on
Dog, a subtlety most users get wrong.recordremains the tool for plain data;structis for when you wouldotherwise reach for
setmetatableby hand.Design principle: inheritance is resolved at compile time
The core design decision, and the reason the runtime model looks the
way it does: everything inheritance-related is paid once per struct at
load time — never per instance, never per method call. There is no
dispatch anywhere.
Why so austere? Because
structis a floor, not a ceilingThis austerity is deliberate.
structis intentionally the simplestthing that removes the metatable boilerplate: a plain table, a plain
__index, explicit copies (X.m = P.m,X.static = P.static), directcalls. Everything it emits is visible in one screen of generated Lua,
teachable without explaining metatables, and debuggable with
print.Concretely, the two decisions that follow from that:
chain is a fixed list of unconditional calls. Runtime method
resolution (virtual calls, method added to a parent after the fact,
supernavigation) requires exactly the machinery we left out —metatable chains between class tables — and every such chain makes
the simple cases slower and the error cases farther away.
resolved dynamically.
Child.config = Parent.configmeans structinstances stored in statics are shared by the whole hierarchy (the
common configuration/singleton/pool pattern just works), while
rebinding a scalar static on the parent after a child exists is
not visible to that child. We consider that a fair trade: shared
mutable state is expressed by mutating a shared instance, which is
unambiguous, instead of rebinding slots, which is where dynamic
lookup actually starts to matter.
None of this is a judgment against virtualization — it is a scope
decision. A future
classkeyword (or a community library) can builddynamic dispatch,
super, and late-bound members on top of thesesemantics or beside them, the way richer OOP layers historically build
on Lua's primitives. Keeping
structminimal leaves that space open:it does not preempt the design questions a
classwould need to answer(multiple dispatch? MRO? open classes?), and users who never need them
never pay for them.
recordfor plain data,structfor OOP withoutceremony,
class— maybe, one day, with all of it.The generated
.newcompiles to:
optsparameter is typed as a record of exactly the struct'sinstance fields — static fields and methods are excluded, so passing
an unknown key is a type error.
== nilchecks (falsy-safe:false/0work correctly), one per defaulted field, in declarationorder. Child defaults override parent defaults; the merge happens at
check time, so a constructor never contains duplicated parent
assignments.
init, the call is omitted entirely — noif X.init thenguard. The checker knows the answer at compile time.The
initchaininitis a lifecycle hook, not a method: it is never flattened, neverinherited as a field.
.newof a child contains a fixed list of directcalls, root parent first, containing only ancestors that declare
their own
init:Every
initruns exactly once, parent to child. Calling a parent hookfrom user code is by name:
A.init(self).Method flattening: no dispatch, ever
At the point a child struct is declared, every method known on its
parent is copied into it:
A method call on an instance is a single table lookup: instance
table → (miss) →
__index→Circle.describe. No chain betweenstruct tables, no resolution at call time. Overrides are simply later
assignments — a method defined on the child after its declaration
overwrites the flattened copy; parent implementations stay reachable by
name (
Shape.describe(self)).Subtyping
A child struct is accepted anywhere any ancestor is expected
(
Child <: Parent, transitive), via nominal ancestry tracked atdeclaration time. The synthesized
.newsignatures are excluded fromthe comparison (each struct's opts record reflects its own fields).
Feature overview
local struct X ... end(alsoglobal)X.new { field = value }— auto-generated,.newis reservedfunction X:init()— no args besidesself, chained root→leaffunction X:m(...)(colon)function X.m(...)(dot)x: number = 0— typechecked against the field typestatic ... endblock; excluded from.newopts; own initializers emitted once on the type; inherited statics copied by reference at the child's declaration (instance statics shared hierarchy-wide)local struct Dog:Animal— single parent; fields, defaults, statics and methods are inheritedlocal type P = Point; struct T:Presolves toPointlocal A = require("animal")(module returns the struct directly) — see belowInstance vs static methods follow Lua's own convention — colon vs dot:
Static methods are ordinary record functions, so children inherit them
through the same compile-time flattening:
Precise.from_fahrenheit(32)works and returns a
Temperature(the declared return type — a factoryfor the child would be declared on the child).
Nominal construction
Table literals are rejected where a struct instance is expected
(
local p: Point = { x = 1 }is a type error, with a hint to usePoint.new): a bare table lacks the metatable wiring that methods andinitrely on.asremains the escape hatch.Cross-module parents
Supported through a top-level
local X = require("mod")whose modulereturns the struct directly — that local provably holds the struct's
runtime table, so the emitted copies and init calls are sound. Guarded
rejections, each with a clear error:
child's declaration site)
init(those tables are notvisible outside the parent's module)
expressions may reference the parent module's locals)
.d.tldeclaration files (runtime shape is bycontract)
Explicit rejections (clear errors, not surprises)
X.new(reserved; the error suggestsinit)neworinitin the body (both are reserved:the synthesized constructor and the lifecycle hook)
struct A:A(self-inheritance){ ... } as Point) — theresult would lack the metatable wiring; casting variables remains
the interop escape hatch
staticblocks and:Parentinrecord/interfacedeclarationsstruct Inner ... endandtype Inner = struct ... endinside a type body); structs declaredinside function bodies and as top-level
local type P = structdeclarations are supported and fully functional
struct X<T>) — not yet, see roadmapCompatibility
recordprogram changes behavior; allpre-existing specs pass unchanged (1925 baseline).
structis not a parallel implementation, though — it shares record'smachinery, which is worth spelling out for review:
RecordTypecarries the struct markers (is_struct, parent/chaintables, default-value maps) as new optional fields,
nilon plainrecords;
subtype_record) and the table-literalchecker each gained a small opt-in branch (
is_structchecks);X:Parent,staticblocks,
type Inner = structnested in a type body — is rejectedwith dedicated errors; these inputs were syntax errors before too,
so only the messages are new;
structis not a reserved word: it is parsed exactly likerecord/enum— usable as an identifier, treated as a typeconstructor only in type positions (same grammar slot).
supported targets (verified
--gen-target=5.1and5.4)..d.tl) can describe struct types for consumers.Testing
75 new specs in
spec/lang/declaration/struct_spec.lua, covering:construction, defaults (well-typed, mistyped, falsy, inherited,
overridden), init chaining (including skipping init-less ancestors and
exact-once semantics), method flattening and overrides, statics
(inheritance by reference — including runtime-verified sharing of
static struct instances across the hierarchy — scalar snapshots,
shadowing,
.newrejection, single-block rule), subtyping andupcasts, alias and cross-module parents (positive + all four guarded
rejections), reserved-name errors (
new,init), declaration-orderenforcement (parent methods/init after child structs are rejected,
including the body-field-then-implementation case), self-inheritance
and table-literal casts, and a general acceptance battery
(metamethods in struct bodies, array interfaces + inheritance,
recursion, 50-deep chains — the latter also verifies the generated
constructors stay linear: one
ifper defaulted field, zero spuriousinit calls).
Full suite: 2001 passing (1793 lang / 96 api / 112 cli).
Documentation
New chapter
docs/src/structs.md(listed in the book summary),including a "How it works: the generated code" section that spells out
the exact Lua emitted for
.new, the init chain, and methodflattening, with a per-feature runtime cost table.
Limitations & future work
init) must precede child structdeclarations: flattening and init chaining capture the parent's state
at each child's declaration, so late declarations would silently not
reach the children. The checker rejects them with a clear error
("declare parent methods before child structs").
struct X<T>is the natural follow-up.super, no runtime dispatch —intentional; parent members are reachable by name. This is the
floor-not-ceiling scoping discussed under Design principle: if
the community wants virtualization and dynamic method resolution,
the natural home is a future
classlayer built beside (or on topof) these primitives, answering its own design questions —
structitself should stay the maximally simple structure it is.
inittakes no arguments besidesself; construction data flowsthrough the
.newopts table.We're happy to adjust naming, error messages, or split this into
smaller PRs (e.g. parser+codegen first, statics/cross-module as
follow-ups) if that eases review.