Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions docs/class-constructors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Constructors

## Summary

Add user-definable constructors to Luau classes.

## Motivation

The initial class RFC specifies that all classes can be constructed by invoking the class object as a function with a mapping from fields to values as its sole argument.

This is great for "plain old data" classes and easily allows users to write their own static `.new()` method if they have more exotic construction requirements, but this falls apart in the face of inheritance because a `.new()` factory necessarily couples the actual class instance construction with the field initialization.

Concretely:

```luau
class BasePoint
public x: number
public y: number

function new(): BasePoint
return BasePoint { x=0, y=0 }
end
end

class DerivedPoint extends BasePoint
public name: string

function new(): DerivedPoint
-- We're stuck! We cannot implement this function in terms of BasePoint.new()

@alexmccord alexmccord Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so? DerivedPoint { x = 1, y = 2, name = "hello" }. The difference is that if the superclass has some field x and the subclass also has some field x, then that subclass must shadow that field x. This is only a problem if you need the side effects from BasePoint.new() or it has private fields. And in both cases, the problem stems entirely from inheritance in the first place.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This argument only stands with "closed universe" assumption when whole hierarchy defined in the same code base. You can image some LibBaseClass(e.g. ReactElement) and UserExtensionClass extends LibBaseClass. In this case every update of library code with addition of fields to LibBaseClass will break UserExtensionClass definition.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but I'd also argue that the open world assumption is problematic, hence why I suggested this.

Consider a superclass with a bunch of fields and methods with default implementation. If someone extends from it, and adds a field that shadows the field in the superclass, then all methods defined in the superclass that depends on that field are now logically broken.

If there's no open world assumption, which is what inheritance implies, then this entire category of problems disappears.

end
end
```

Implementation inheritance requires some mechanism by which a class constructor can do the work only to initialize its part of the object for some class that is at some indeterminate point in an inheritance hierarchy.

This is a very well-known problem. The well-known solution is constructors.

## Design

We draw inspiration from Python and allow classes to replace the builtin constructor by defining an `__init` method:

```luau
class A extends B
public x: number

function __init(self, x, y)

@alexmccord alexmccord Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is so much like Python's __postinit. Then you'd only write the last line there, and the constructor call probably has to invoke this metamethod anyway. Then there's no need for T.new being sugar for __init (not to mention what happens if the function new exists along with __init).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in a dataclasses __post_init__? It feels much more akin to __init__ given a DC's __post_init__ only runs after initialisation, where you'd do (in Luau class terms) A { x = 1, ... }, rather than a custom A(some, arguments, here) constructor, then the post-init gets called on an already-populated object. This proposal seems to be focusing on self being entirely uninitialised at the start of __init, which would just be a normal __init__ on a python class.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, __post_init__ from Python's @dataclass, yeah. But re-reading this RFC, this specific class is better done with __post_init__ but the general problem is still not solvable with that one. Constructors vs new really does need to exist (kinda).

B.__init(self, x)
self.x = x * y
end
end
```

If a class defines an `__init` function, it is understood to be a constructor. Classes with constructors follow different rules. We define class construction as follows:

Classes that have constructors cannot be constructed via `T()` syntax. Instead, the static method `.new` must be invoked. Classes that do not define constructors are still initialized via `T {...}` syntax.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof. :(


Let `T` be a class object that defines a constructor. When `T.new(...args)` is invoked with any arguments, the following happens:

1. A fresh, uninitialized instance of `T` is allocated. We'll call it `t` here. All of its fields are initially `nil` irrespective of any type annotations.
2. `T.__init(t, ...args)` is invoked.
3. `t` is produced as the result of the expression

Classes are forbidden from expressly defining a `.new` method. It is reserved by the language.

### Rules of Constructors

In order to make uninitialized data unobservable, constructors are required to follow some strict rules:

* The first argument of a constructor must be `self`.
* A class must define a constructor if its base class defines one
* If the base class defines a constructor, the class constructor must invoke it before reading or writing to `self` or any of its properties
* A constructor must unconditionally initialize all of its fields before it can pass `self` to any function.
* The delegating call to the base class constructor is of course exempt from this. `BaseClass.__init(self, args)`
* This restriction also includes all method calls (eg `self:something()`)
* For v1, type inference does not attempt to track fields that are initialized within conditional constructs like `if` statements or loops. Fields must be initialized directly at the function scope.
* As a special exception, fields whose types are supertypes of `nil` are exempt from this requirement and are always considered to have been implicitly initialized with `nil`. Explicit initialization of such fields is of course permitted.
* Additionally, any subexpression where `self` is explicitly cast is exempt from this. (eg `foo(self :: any)` or even `foo(self :: FooType)`) We offer this as an intentional way for a developer to override the type system if they need to.
* A constructor must not refer to any field before it has been initialized.

Once the base class has been called and all fields are initialized, constructors can do anything.

These rules are all enforced only by type checking. When the program is run, uninitialized fields will have the value `nil` no matter what their types might say.

## Drawbacks

Constructors add more complexity to the language. Some classes can be initialized via `T {x=x, y=y}` syntax and others must be initialized with different kinds of arguments.

We intentionally only check that fields are initialized in type checking. This means that our runtime still has to be able to cope with fields that have been left uninitialized.

Some developers will feel inconvenienced by the restrictions on uninitialized class fields. The current rules do not, for instance, permit a developer to write a helper function that partially (or completely) initializes a new class instance.

## Alternatives

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another alternative (that I already liked, but am starting to really like it) is "Do not support inheritance" and now this entire category of problems are gone.


We could follow in Python's footsteps and do away with the default `T{}` constructor, but this means that developers have to write a lot of dull code in the typical "plain old data" case:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would actually prefer this even more, as the way "default table constructors" for classes currently work really irritates me.

First, as I've said before, the current design conflates the idea that the fields of a class are also its constructor. This poses awkward questions regarding how private and static fields are treated and is just a big mismatch with the current syntax. Languages that lay out their class fields in a spreadsheet manner make the programmer write out the constructor by hand, but Luau doesn't and generates one automatically, which is weird.

Second, the table constructor protocol requires at a bare minimum to allocate a throwaway table that is then passed to a C function that then sanitizes and allocates the real object from it. This implies that baseline performance for construction will always be worse than just allocating a regular table. The justification that future compiler heroics will cut back on the performance hit feels more like a band-aid solution, and I'd expect a stronger foundation for the runtime side than that.

Third, this RFC is proposing an alternative constructor protocol that supersedes most if not all uses for the default table constructor. If that's the case, why even support table construction to begin with? If someone feels like the constructor boilerplate is "pointless ceremony" for a POD datatype, then maybe the syntax for classes needs to be revisited to incorporate the idea of default constructors better.

@TenebrisNoctua TenebrisNoctua Jun 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I think a C++ like syntax would be more ideal, where the class object can still be called like a function, however you do not allocate a table for initializing the fields. Instead, you pass arguments to this function, which if defined, will pass these arguments to a constructor. If there is no constructor, then the fields will not be initialized, and therefore nil.

class Foo
    protected x: number
    public function Foo(self)
        self.x = 51
    end
end

class Bar extends Foo
    public function Bar(self)
        Foo(self)
    end
end

local newFoo = Foo()
local newBar = Bar()

Foo() now applies magic (the same in the proposed __init) and constructs a new, but uninitialized instance of the class. This instance is then passed to the constructor, in which the function can use to initialize its fields.

However, this constructor can also be called with another object which is made within a subclass, in which the constructor's self parameter points to this object, rather than a new instance of Foo.

One problem with this could be that there is an ambiguity between the argument types. To solve that problem, Luau could check whether or not the first argument to the class function is an object which is made from a subclass.

class Foo
    protected x: number
    public function Foo(self, x: number)
        self.x = x
    end
end

class Bar extends Foo
    private y: string
    public function Bar(self, x: number, y: string)
        Foo(self, x)
        self.y = y
    end
end

local newFoo = Foo(51)
local newBar = Bar(51, "Hello")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's sort of what I had in mind as well, minus the inheritance bit. Anything but a default table constructor so that the class can be truly encapsulated should private and static fields be added.

And for POD cases, "record"-style syntax sugar could be added which generates a constructor automatically, i.e. class Point(x: number, y: number) end.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels similar to the proposal in this RFC, but with better semantics and you can still use .new() however you please. I wonder if this is worthy of an amendment RFC, or should be included in this RFC instead.


```luau
class Point
public x: number
public y: number

-- This whole function is pointless ceremony
function __init(self, x, y)
self.x = x
self.y = y
end
end
```

We could make field initialization more logical by adding C++-style initializer syntax. We're already adding a lot of syntax and I don't think it's worth it for us.

The choice to construct instances via a `.new` static function is motivated by Lua libraries that effect objects. We sacrifice the ability for classes to define their own `.new` method but in exchange, code using classes looks more consistent with what came before.