Skip to content

Add struct keyword for ergonomic OOP over records - #1161

Open
akvilary wants to merge 12 commits into
teal-language:mainfrom
akvilary:feat/struct-oop
Open

Add struct keyword for ergonomic OOP over records#1161
akvilary wants to merge 12 commits into
teal-language:mainfrom
akvilary:feat/struct-oop

Conversation

@akvilary

@akvilary akvilary commented Aug 18, 2026

Copy link
Copy Markdown

Add struct keyword for ergonomic OOP over records

Reviewing this PR

Changes were made in 10 files — everything else is regenerated
build output:

  • language & docs: 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.md
  • tests: spec/lang/declaration/struct_spec.lua

teal/*.lua, teal.lua and tl.lua are self-build artifacts (Teal
compiles its own .tl sources; the compiled output is committed per
repo convention). The blank-line runs there are the generator's
newline preservation: comments and type declarations in .tl produce
no code, and the output is padded 1:1 so line numbers stay aligned
between .tl and .lua (this is what makes tl run errors point at
the right source lines). Reviewers can skip those files entirely.

Summary

This PR introduces a struct declaration: a thin layer over record that
removes 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 record program changes
behavior — but it is built into record's machinery rather than bolted
on beside it (see Compatibility for exactly what that means).

local struct Animal
   name: string
   sound: string = "..."         -- field default
   legs: number = 4

   static                       -- type-level fields
      KINGDOM: string = "Animalia"
      count: number = 0
   end
end

function Animal:init()          -- lifecycle hook, chained root->leaf
   Animal.count = Animal.count + 1
end

function Animal:speak(): string
   return self.name .. " says " .. self.sound
end

local struct Dog:Animal          -- single inheritance
   breed: string
end

function Dog:init()
   self.sound = "Woof"
end

local d = Dog.new { name = "Rex", breed = "Labrador" }
print(d:speak())                 --> Rex says Woof
print(d.legs, d.KINGDOM)         --> 4    Animalia (default; static via __index)
print(Animal.count)              --> 1

Two design decisions define the whole feature:

  1. Inheritance is strictly single. A struct has at most one parent
    (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.
  2. Method calls have zero dispatch. No lookup chains, no metatable
    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:

-- what users write today, over and over
function Animal.new(name: string): Animal
   local self: Animal = setmetatable({}, { __index = Animal })
   self.name = name
   return self
end

struct removes this boilerplate, and builds the pieces the hand-written
pattern cannot express on top of it:

  • an auto-generated .new whose parameter is a typed record of the
    struct's instance fields (unknown keys are type errors, statics and
    methods are excluded);
  • an init lifecycle hook with a cascade: .new of a child runs
    every init in the hierarchy, root parent first, each exactly once —
    something each hand-rolled constructor would have to re-implement
    (and usually doesn't);
  • a static ... end block for type-level fields (constants, shared
    state): they get a proper declaration site, are set once at load
    time, are readable through instances via __index, and .new
    rejects them in opts. Today this is ad-hoc discipline — assignments
    scattered after the type definition, invisible to the type checker;
  • single inheritance with compile-time method flattening and
    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 table
    itself to see the parent's members you'd need a second metatable on
    Dog, a subtlety most users get wrong.

record remains the tool for plain data; struct is for when you would
otherwise reach for setmetatable by 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 struct is a floor, not a ceiling

This austerity is deliberate. struct is intentionally the simplest
thing that removes the metatable boilerplate: a plain table, a plain
__index, explicit copies (X.m = P.m, X.static = P.static), direct
calls. 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:

  • No dispatch, ever. A method call is one table lookup; the init
    chain is a fixed list of unconditional calls. Runtime method
    resolution (virtual calls, method added to a parent after the fact,
    super navigation) 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.
  • Static fields are copied by reference at declaration, not
    resolved dynamically.
    Child.config = Parent.config means struct
    instances 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 class keyword (or a community library) can build
dynamic dispatch, super, and late-bound members on top of these
semantics or beside them, the way richer OOP layers historically build
on Lua's primitives. Keeping struct minimal leaves that space open:
it does not preempt the design questions a class would need to answer
(multiple dispatch? MRO? open classes?), and users who never need them
never pay for them. record for plain data, struct for OOP without
ceremony, class — maybe, one day, with all of it.

The generated .new

local struct Point
   x: number
   y: number = 0
   distance: number
end

function Point:init()
   self.distance = math.sqrt(self.x ^ 2 + self.y ^ 2)
end

compiles to:

local Point = {}
Point.__index = Point
Point.new = function(opts)
   local self = setmetatable({}, Point)
   for k, v in pairs(opts) do self[k] = v end
   if opts.y == nil then self.y = 0 end    -- one `if` per defaulted field
   Point.init(self)                        -- unconditional direct call
   return self
end
  • The opts parameter is typed as a record of exactly the struct's
    instance fields — static fields and methods are excluded, so passing
    an unknown key is a type error.
  • Defaults are applied with explicit == nil checks (falsy-safe:
    false/0 work correctly), one per defaulted field, in declaration
    order. Child defaults override parent defaults; the merge happens at
    check time, so a constructor never contains duplicated parent
    assignments.
  • If the struct declares no init, the call is omitted entirely — no
    if X.init then guard. The checker knows the answer at compile time.

The init chain

init is a lifecycle hook, not a method: it is never flattened, never
inherited as a field. .new of a child contains a fixed list of direct
calls, root parent first, containing only ancestors that declare
their own init
:

-- C extends B extends A; B declares no init
C.new = function(opts)
   local self = setmetatable({}, C)
   for k, v in pairs(opts) do self[k] = v end
   A.init(self)          -- B is absent from the chain: no cost, no guard
   C.init(self)
   return self
end

Every init runs exactly once, parent to child. Calling a parent hook
from 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:

local Circle = {}
Circle.__index = Circle
Circle.describe = Shape.describe        -- flattened at declaration
Circle.new = function(opts) ... end

A method call on an instance is a single table lookup: instance
table → (miss) → __indexCircle.describe. No chain between
struct 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 at
declaration time. The synthesized .new signatures are excluded from
the comparison (each struct's opts record reflects its own fields).

Feature overview

Feature Syntax
declaration local struct X ... end (also global)
construction X.new { field = value } — auto-generated, .new is reserved
init hook function X:init() — no args besides self, chained root→leaf
instance methods function X:m(...) (colon)
static methods function X.m(...) (dot)
field defaults x: number = 0 — typechecked against the field type
static fields static ... end block; excluded from .new opts; own initializers emitted once on the type; inherited statics copied by reference at the child's declaration (instance statics shared hierarchy-wide)
inheritance local struct Dog:Animal — single parent; fields, defaults, statics and methods are inherited
parent via alias local type P = Point; struct T:P resolves to Point
cross-module parent local A = require("animal") (module returns the struct directly) — see below

Instance vs static methods follow Lua's own convention — colon vs dot:

local struct Temperature
   celsius: number

   static
      ABSOLUTE_ZERO: number = -273.15
   end
end

-- static method: dot syntax, no self — a factory on the type
function Temperature.from_fahrenheit(f: number): Temperature
   return Temperature.new { celsius = (f - 32) * 5 / 9 }
end

-- instance method: colon syntax, self is the instance
function Temperature:to_fahrenheit(): number
   return self.celsius * 9 / 5 + 32
end

local t = Temperature.from_fahrenheit(212)
print(t:to_fahrenheit())          --> 212.0
print(Temperature.ABSOLUTE_ZERO)  --> -273.15

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 factory
for 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 use
Point.new): a bare table lacks the metatable wiring that methods and
init rely on. as remains the escape hatch.

Cross-module parents

Supported through a top-level local X = require("mod") whose module
returns 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:

  • field paths / bare globals (no runtime presence guarantee at the
    child's declaration site)
  • parents whose own ancestors declare init (those tables are not
    visible outside the parent's module)
  • parents with computed (non-literal) default values (their
    expressions may reference the parent module's locals)
  • structs described by .d.tl declaration files (runtime shape is by
    contract)

Explicit rejections (clear errors, not surprises)

  • user-declared X.new (reserved; the error suggests init)
  • data fields named new or init in the body (both are reserved:
    the synthesized constructor and the lifecycle hook)
  • struct A:A (self-inheritance)
  • casting a table literal to a struct type ({ ... } as Point) — the
    result would lack the metatable wiring; casting variables remains
    the interop escape hatch
  • static blocks and :Parent in record/interface declarations
  • nested structs — in both forms (struct Inner ... end and
    type Inner = struct ... end inside a type body); structs declared
    inside function bodies and as top-level local type P = struct
    declarations are supported and fully functional
  • generic structs (struct X<T>) — not yet, see roadmap
  • incompatible field overrides in children
  • static fields shadowing instance fields

Compatibility

  • No previously-valid record program changes behavior; all
    pre-existing specs pass unchanged (1925 baseline).
  • struct is not a parallel implementation, though — it shares record's
    machinery, which is worth spelling out for review:
    • RecordType carries the struct markers (is_struct, parent/chain
      tables, default-value maps) as new optional fields, nil on plain
      records;
    • the shared subtyping rule (subtype_record) and the table-literal
      checker each gained a small opt-in branch (is_struct checks);
    • struct-only syntax applied to a record — X:Parent, static
      blocks, type Inner = struct nested in a type body — is rejected
      with dedicated errors; these inputs were syntax errors before too,
      so only the messages are new;
    • struct is not a reserved word: it is parsed exactly like
      record/enum — usable as an identifier, treated as a type
      constructor only in type positions (same grammar slot).
  • Struct instances are ordinary tables; the emitted Lua runs on all
    supported targets (verified --gen-target=5.1 and 5.4).
  • Declaration files (.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, .new rejection, single-block rule), subtyping and
upcasts, alias and cross-module parents (positive + all four guarded
rejections), reserved-name errors (new, init), declaration-order
enforcement (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 if per defaulted field, zero spurious
init 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 method
flattening, with a per-feature runtime cost table.

Limitations & future work

  • Parent methods (including init) must precede child struct
    declarations: 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").
  • Generic structs are rejected with a clear error; supporting
    struct X<T> is the natural follow-up.
  • No multiple inheritance, no 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 class layer built beside (or on top
    of) these primitives, answering its own design questions — struct
    itself should stay the maximally simple structure it is.
  • init takes no arguments besides self; construction data flows
    through the .new opts 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.

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.
@Frityet

Frityet commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

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 :init and then use .new instead is counterintuitive and weird. Also, how does a subclass invoke the super classes constructor (actually, looks like you cant do any params for ctors? why?)?

@lenscas

lenscas commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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.

@akvilary

akvilary commented Aug 25, 2026

Copy link
Copy Markdown
Author

"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).
@akvilary

akvilary commented Aug 26, 2026

Copy link
Copy Markdown
Author

"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.
struct allows to user not to pay more if scenario is simple.

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.

3 participants