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
290 changes: 290 additions & 0 deletions docs/syntax-classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
# Classes

## Summary

```luau
class Point
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 9, 2026

Choose a reason for hiding this comment

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

It'd be nice to be able to write class MyClass: SomeSignature & SomeOtherSignature ... end as a way to make sure MyClass satisfies these requirements (or to unify with any metavariables in the class fields/methods).

public x: number
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.

Is there a way for me to define a static member such as Point.zero?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not yet. It's a good idea for an extension though.

public y
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can class fields have default values?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not yet. Sounds like a nice extension though.

Comment on lines +7 to +8
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 7, 2026

Choose a reason for hiding this comment

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

What if we do the crazy thing and said that fields need commas or semicolons? Then the bare fields like these just doesn't look so strange after all.

class A
  x,
  y, -- could omit the trailing comma
end

So for this class:

class Point
  x: number,
  y,
  
  public function new(x, y)
    return Point { x = x, y = y }
  end
  
  public function components(self)
    return self.x, self.y
  end

  public function length(self)
    local x, y = self:components()
    return math.sqrt(x*x + y*y)
  end
  
  public function __add(self, rhs: Point)
    local lx, ly = self:components()
    local rx, ry = self:components()
    return Point.new(lx+rx, ly+ry)
  end
end


function length(self)
return math.sqrt(self.x * self.x + self.y * self.y)
end

function __add(self, other: Point)
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 6, 2026

Choose a reason for hiding this comment

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

Do we need to do the C++ thing and say identifiers in classes with two leading underscores are not valid methods, fields, nor are they metamethods, except for all existing metamethods that makes sense for an object?

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.

We would have to exclude __mode too. "k" is obviously nonsense, and I think "v" is also nonsense since GC has to handle cyclic data anyway.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Do we need to do the C++ thing and say identifiers in classes with two leading underscores are not valid methods, fields, nor are they metamethods, except for all existing metamethods that makes sense for an object?

I had been leaning "no," but it would be a nice way to catch some simple errors. (eg writing __less instead of __lt because you forgot the exact name)

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.

For me, it was about ensuring forward compatibility with any new metamethods Luau may have in the future.

I think because of that, I'd rather flip from a denylist to an allowlist?

return Point { x = self.x + other.x, y = self.y + other.y }
end

function __tostring(self)
Comment thread
andyfriesen marked this conversation as resolved.
return `Point \{ x = {self.x}, y = {self.y} \}`
end

function new(x, y)
return Point { x = x, y = y }
Comment thread
andyfriesen marked this conversation as resolved.
end
end

local p = Point.new(3, 4)
print(`Check out my cool point: {p} length = {p:length()}`)
```

## Motivation

* People write object-oriented code. We should afford it in a polished way.
* Accurate type inference of `setmetatable` has proven to be very difficult to get right. Because of this, the quality of our autocomplete isn't what it could be.
* A construct with a fixed shape and a completely locked-down metatable will open up optimization opportunities that could improve performance:
* If classes can only be declared at the top scope, then we know that each method of each class has exactly one instance. This makes it simple for the compiler to know the exact function that will be invoked for any method call expression.
* If a value is known to be an instance of a particular class, the bytecode compiler should be able optimize method calls to skip the whole `__index` metamethod process and instead generate code to directly call the correct method.
* By the same token, method calls can be inlined more aggressively. Particularly self-method calls eg `self:SomeOtherMethod()`
* Field accesses can compile to a simple integral table offset so that the VM doesn't need to do a hashtable lookup as the program runs.
* Since every instance of a class has the same set of properties, we can split the hash table: The set of fields can be associated with the class and instances only need to carry the values of those fields. We think this can improve performance by improving cache locality.

## Design

### Syntax

Class definitions are a block construct. They can only be written at the topmost scope. `export class X` is allowed.

Defining two classes with the same name in the same module is forbidden.

Within a class block, two declarations are allowed: Fields and methods.
Copy link
Copy Markdown
Contributor

@karl-police karl-police Apr 21, 2026

Choose a reason for hiding this comment

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

What would happen if one makes a field, such as .data, which could be anything.

in the old method, you'd probably have self.data = {} and as you fill it in, in the "class.new" constructor, you'd mutate the entire object. Though I think Luau would be able to know and infer data and its mutation into object correctly?

And what about initializing functions? Some would initialize things, mid running .new()

function new(x, y)
      local newPoint = Point { x = x, y = y }
      newPoint:PrepareThings()
      return newPoint 
end

as in

function Point.new(x, y)
      local newPoint = setmetatable({ x = x, y = y }, Point)
      newPoint:PrepareThings()
      return newPoint 
end

 

And what would happen in this case here?

function new(x, y)
      local newPoint = Point { data = {} }
      newPoint.data.entry = "test"
      return newPoint 
end

Would it know about .data having entry in it? Because this is the dynamic way, on how you'd do it in the previous way. Along with any kind of table "composing" functions as well.


Fields are introduced with the new `public` keyword. We also plan to eventually offer `private`, but is sufficiently complex that it merits its own RFC.

Methods are introduced with the familiar `function` keyword. `public function f()` is also permitted.
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.

public function being allowed was a very tiny detail that is only mentioned exactly once. Maybe add a few more examples (or modify some of them) that makes use of this?

Right now, all functions in classes are without public, and I'm a believer in privacy by default, so really functions are private to the module.


Methods defined on class objects can be accessed either via `Class.method()` or `instance:method()` syntax.

If a method's first argument is named `self`, it should be invoked with the familiar `instance:method()` call syntax. This is not strictly required, but the compiler and optimizers may deoptimize code that doesn't. Type annotations on the `self` parameter are not allowed.

If a method accepts no arguments or if its first argument is not named `self`, it should be invoked via `Class.method()` syntax. This is the same as "static methods" from other languages.
Comment on lines +52 to +60
Copy link
Copy Markdown

@greentheblaze greentheblaze Apr 21, 2026

Choose a reason for hiding this comment

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

I haven't seen any explicit mentions of whether or not fields can be defined as functions. I can therefore only assume by the fact there isn't a good enough distinction between methods and fields, that defining fields as functions would currently be impossible—at least from outside constructor functions...

...but what about inside constructors? You can define callbacks in JavaScript classes like so:

class Foo {
    constructor(bar) {
        this.bar = bar;

        // Callback attribute:
        this.onJarp = () => {
            console.log(`${this.bar} yarb!`);
        };
    }

    jarp() {
        if (typeof this.onClick === 'function') {
            // Invoke the callback:
            this.onJarp();
        }
    }
}

// Usage example
const myFoo = new Foo('Bar');

// Custom callback assigned later
myFoo.onJarp = () => {
    console.log('This is a custom callback!');
};

// Trigger the callback
myButton.jarp();

Just wondering whether or not Luau could support this type of functionality. I'd prefer not to be forced to define callbacks through a method, as the use of fields and methods should convey intention about what exactly is changing about a class's instance; fields are used to house data that can be used either internally, or externally; methods are the actions that change, or are shown to "do something" to the instance. Setting a callback through a method isn't really "doing something" to the object, which is why I am not really a fan of that approach.

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.

Fields aren't immutable as far as I understand so the following should work, especially considering classes are meant to be a replacement for metatable classes.

class Foo
     public onJarp: ((foo: Foo) -> ())?
     
     function create(bar: string)
          local function onJarp(foo: Foo)
               print(`{foo.bar} yarb!`)
          end
          return Foo { bar = bar, onJarp = onJarp }
      end
      
      function Jarp(self)
           if self.onJarp then
                self.onJarp(self)
           end
      end
end

const myFoo = Foo.create("Bar")

myFoo.onJarp = function(Foo)
     print("This is a custom callback!")
end

myFoo:Jarp()

Copy link
Copy Markdown

@greentheblaze greentheblaze Apr 21, 2026

Choose a reason for hiding this comment

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

The only awkward area I can still see is that there is no way to initialize functions as attributes without accidentally creating methods outside of constructors. It's annoying for one as it means that you may end up having to define the same logic multiple times throughout differing constructors (if you're going to use "static methods") that would otherwise be included in class definitions statements instead.

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 wonder how or how complex it will be for code editors to jump to the implementation of onJarp


To create a new instance of a class, invoke it as if it were a function. It accepts one argument: A table that describes the initial values of all its properties. If more customization is desired, static factory functions (frequently named `new()` or `create()`) are an easy, familiar way to accomplish this.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does anything happen if the table has excess properties that aren't present in the 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.

I'd really like for that to be an error.

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.

Also this should error too. Just to be explicit about it.

local t = setmetatable({ x = 1 }, {
  __index = function(self, k)
    return if k == "y" then 2 else nil
  end
})

class Point
  x: number,
  y: number,
end

local pt = Point(t)

The question is whether this should also error, though:

local t = setmetatable({ x = 1, y = 2 }, { --[[ any metatables at all ]] })

class Point
  x: number,
  y: number,
end

local pt = Point(t)


Classes can define the following Luau metamethods. They all work just like they do on a metatable:

* `__call`
* `__concat`
* `__unm`
* `__add`
* `__sub`
* `__mul`
* `__div`
* `__mod`
* `__pow`
* `__tostring`
* `__eq`
* `__lt`
* `__le`
* `__iter`
* `__len`
* `__idiv`

For forward-compatibility, it is a syntax error to define any other method whose
name starts with two underscores.
Comment on lines +81 to +84
Copy link
Copy Markdown
Contributor

@karl-police karl-police Apr 21, 2026

Choose a reason for hiding this comment

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

I don't see __index and or __newindex on there.

Now, I am not suggesting that if someone would re-define __index in a class, that it's like 1:1 same behavior as in a table. I am more thinking of sugar.

But I was looking for what types of classes exist, and found this special one. It seems to have self.data, it's some kind of array. It's like as if someone made something like Dictionary in C#, you'd be able to index into the object as in like dict["something"], but at the same time, you're also able to use methods dict.TryGetValue(). But since __newindex or __index isn't mentioned, you kinda can't do that?

Unless you'd make a proxy, but a proxy, is just another hack, for these 2 things. Proxies maybe are useful for other debugging. But a proxy for these two things, doesn't feel like it.

Details
--[[
    A general purpose n-dimensional vector library. mul and div support either scalar multiplication or componentwise with another vector
]]

local FloatVector = {}

type FloatVectorMembers = { data: { number } }

export type FloatVector = typeof(setmetatable({} :: FloatVectorMembers, FloatVector))

-- input: Another FloatVector or tuple of number values
function FloatVector.new(...): FloatVector
	local new = setmetatable({} :: FloatVectorMembers, FloatVector)
	new.data = {}

	local inputTable = { ... } :: any
	if #inputTable == 1 then
		if typeof(inputTable[1]) == "table" then
			if getmetatable(inputTable[1]) :: any == FloatVector then
				inputTable = (inputTable[1] :: FloatVector).data
			else
				inputTable = inputTable[1] :: { number }
			end
		end
	end

	for _, component in inputTable do
		if typeof(component) ~= "number" then
			error("arg to FloatVector.new() not a number")
		else
			table.insert(new.data, component :: number)
		end
	end
	return new
end

function FloatVector:getSize(): number
	return #self.data
end

function FloatVector.checkCompatible(lhs: FloatVector, rhs: FloatVector): boolean
	if typeof(lhs) ~= typeof(FloatVector) or typeof(rhs) ~= typeof(FloatVector) then
		error("vector operation on non-vector type")
		return false
	end

	if #lhs.data ~= #rhs.data then
		error("operating on vectors of different dimension")
		return false
	end
	return true
end

function FloatVector.__index(table, key: any)
	if typeof(key) == "number" then
		return table.data[key :: number]
	end

	return FloatVector[key]
end

function FloatVector.__newindex(table, key, value)
	if typeof(key) == "number" then
		table.data[key :: number] = value :: number
		return
	end

	rawset(table, key, value)
end

function FloatVector.__add(lhs: FloatVector, rhs: FloatVector): FloatVector
	FloatVector.checkCompatible(lhs, rhs)
	local result = {}
	for i = 1, #lhs.data, 1 do
		table.insert(result, lhs.data[i] + rhs.data[i])
	end
	return FloatVector.new(result)
end

function FloatVector.__sub(lhs: FloatVector, rhs: FloatVector): FloatVector
	FloatVector.checkCompatible(lhs, rhs)
	local result = {}
	for i = 1, #lhs.data, 1 do
		table.insert(result, lhs.data[i] - rhs.data[i])
	end
	return FloatVector.new(result)
end

function FloatVector.__mul(lhs: FloatVector, rhs: FloatVector | number): FloatVector
	if typeof(lhs) == typeof(FloatVector) and typeof(rhs) == "number" then
		local result = {}
		for _, component in lhs.data do
			table.insert(result, component * rhs :: number)
		end
		return FloatVector.new(result)
	else
		FloatVector.checkCompatible(lhs, rhs)
		local result = {}
		for i = 1, #lhs.data, 1 do
			table.insert(result, lhs.data[i] * rhs.data[i])
		end
		return FloatVector.new(result)
	end
end

function FloatVector.__div(lhs: FloatVector, rhs: FloatVector | number): FloatVector
	if typeof(lhs) == typeof(FloatVector) and typeof(rhs) == "number" then
		local result = {}
		for _, component in lhs.data do
			table.insert(result, component / rhs :: number)
		end
		return FloatVector.new(result)
	else
		FloatVector.checkCompatible(lhs, rhs)
		local result = {}
		for i = 1, #lhs.data, 1 do
			table.insert(result, lhs.data[i] / rhs.data[i])
		end
		return FloatVector.new(result)
	end
end

function FloatVector:dot(rhs): number
	FloatVector.checkCompatible(self, rhs)
	local result = 0
	for i = 1, #self.data, 1 do
		result += self.data[i] * rhs.data[i]
	end
	return result
end

-- must be a 3 dimensional vector
function FloatVector:cross(rhs): FloatVector?
	FloatVector.checkCompatible(self, rhs)
	if #self.data ~= 3 or #rhs.data ~= 3 then
		error("cross product is only defined for two 3 dimensional vectors")
		return nil
	end
	return FloatVector.new(
		self.data[2] * rhs.data[3] - self.data[3] * rhs.data[2],
		self.data[3] * rhs.data[1] - self.data[1] * rhs.data[3],
		self.data[1] * rhs.data[2] - self.data[2] * rhs.data[1]
	)
end

function FloatVector:magnitude(): number
	local sqSum = 0
	for _, component in self.data do
		sqSum += component ^ 2
	end
	return math.sqrt(sqSum)
end

function FloatVector:fuzzyEq(rhs: FloatVector, eps: number): boolean
	if not FloatVector.checkCompatible(self, rhs) then
		return false
	end
	eps = if eps then eps else 0.00001
	for i = 1, #self.data, 1 do
		if math.abs(self.data[i] - rhs.data[i]) > eps then
			return false
		end
	end
	return true
end

return FloatVector

Copy link
Copy Markdown

@greentheblaze greentheblaze Apr 21, 2026

Choose a reason for hiding this comment

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

But since __newindex or __index isn't mentioned, you kinda can't do that?](#191)

It may be coming in the future as mentioned here: #191 (comment)


#### Class Objects

The action of evaluating a class definition statement introduces a *class object* in the module scope. A class object is a value that serves as a factory for instances of the class and as a namespace for any functions that are defined on the class.

Class objects behave like class instances in most ways, but are always `const` and frozen.

Taking references to class methods via `ClassName.method` syntax is allowed so that classes can easily compose with existing popular APIs:

```luau
local n = pcall(SomeClass.getName, someClassInstance)
```

To construct an instance of a class, call the class object as though it were a function. It accepts a single argument: a table-like value that contains initial values for all the fields. While it will typically be most useful to pass a table literal to this function, that isn't the only use. For example, any class can be shallowly cloned by passing it to its class constructor: `local clone = MyClass(original)`
Comment thread
andyfriesen marked this conversation as resolved.

The top type of all class objects is named `class`. `type()` and `typeof()` return `"class"` when passed a class object.

#### Class Instances

Class instances are a new type of value in the VM. They are similar but not quite the same as tables. They have no array part, for instance.

`pairs`, `ipairs` , `getmetatable`, and `setmetatable` do not work on class instances. They also cannot be iterated over with the generic `for` loop. (unless the class implements `__iter`)

Reading or writing a nonexistent class property raises an exception. This makes it easy to disambiguate between a nonexistent property and a property whose value is nil.

We introduce a new top type for class instances: `object`. The builtin `type()` and `typeof()` functions return `"object"` for any class instance. We chose this over having them return the class name because class names do not have to be globally unique (they must only unique within a single module) and because we do not want to make it possible for classes to impersonate other types.

```luau
class Cls end
local inst = Cls {}

type(Cls) == "class"
typeof(Cls) == "class"

type(inst) == "object"
typeof(inst) == "object"
```

Comparisons between object instances is the same as with tables: If `__eq` is not defined, object comparisons use physical (pointer) equality. `__eq` is only invoked if both operands are the same type.

#### The `class` library

We introduce a new global library `class`. Its contents are

```luau
local class: {
isinstance: (o: object, C: class) -> boolean,
classof: (o: object) -> class?,
}
```

This library also serves as an obvious extension point for future features like reflection.

The function `class.isinstance(o, Class)` returns `true` if the object `o` is an instance of `Class`. At runtime, it raises an exception if the second argument is not a class object. If the first argument is not a class instance, `class.isinstance` returns false. (eg `class.isinstance(5, MyClass)`)

### Type System

Class definitions also introduce a new type to the type environment.

Unlike tables, which are structurally typed, class types are nominal. Two different classes with identical fields are treated as distinct types.

Inferring the types of class fields is fraught with difficulty, so un-annotated fields are given the type `any`.
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.

unknown? If it's any, you will never get any errors.


The type introduced by a class definition is available anywhere in the source file.

The `class.isinstance` function participates in refinement:

```luau
function foo(p: unknown)
if class.isinstance(p, Point) then
return {p.x, p.y} -- no error here
end
end
```

Each class object is a singleton instance of an unnamed type. If needed, it is easy to access via `typeof(TheClass)`. Class object types are all subtypes of the top `class` type.

### Semantics

Class definitions are Luau statements just like function definitions.

The action of a class definition statement is to allocate the class object, define its functions and properties, and freeze it. Consequently, a class cannot be instantiated before this statement is executed.

We do, however, *hoist* the class identifier's binding to the top of the script so that it can be referred to within functions or classes that lexically appear before the class definition. This makes it easy and straightforward for developers to write classes or functions that mutually refer to one another.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Class hoisting implies the following code is valid:

local function getFoo()
    local f = Multiple.new(2)
    return f
end

print(getFoo()) -- ???

local function getData(factor: number)
    return 123 * factor
end

class Multiple
    public factor: number

    function length(self)
        return getData(factor)
    end
end

What is the expected behavior where getFoo() is called? A runtime error?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah. It's a runtime error. Multiple will have the value nil if getFoo is invoked before the class statement is evaluated. The RFC describes this a little bit further into the document.

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 am completely against introducing any hoisting to the language, with this case being a perfect example of why.

If someone needs to reference a class before it is defined, they should use a forward-declared variable as a surrogate to explicitly show that they are using a token that may or may not be defined yet.

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.

Also, the presence of hoisting seems to be part of the reason why class definitions aren't allowed outside the top scope, since hoisting scoped variables can get messy. I would wager everyone would prefer to have scoped classes over class hoisting if they had to choose between the two.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Without hoisting, there is no way at all to write two classes that mutually refer to one another.

The top-level constraint is about establishing that there is exactly on instance of every method of every class. This makes it significantly easier for us to do static method dispatch.

I'll update the RFC to clarify this. Thanks!

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 7, 2026

Choose a reason for hiding this comment

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

There's nothing wrong here. Classes as a statement has zero code to execute outside of the initialization. Fields do not have an initializer. Just don't let functions or locals reference any classes that are defined later.

local function foo()
  return Foo.new()
  --     ^^^ `Foo` is not bound in scope here
end

class Foo -- now `Foo` is bound in scope
  bar: Bar
  
  function new()
    return Foo { bar = Bar { x = 5 } }
  end
end

print(Foo.new().bar.x) -- prints 5. It's fine.

class Bar -- now `Bar` is bound in scope
  x: number
end

-- ...the rest of the module...

The right way to do the hoisting is by doing something like this:

class <unutterable-1>
  bar: <unutterable-2>
  
  function new()
    return <unutterable-1> { bar = <unutterable-2> { x = 5 } }
  end
end

class <unutterable-2>
  x: number
end

local function foo()
  return Foo.new()
  --     ^^^ `Foo` is not bound in scope here
end

const Foo = <unutterable-1>

print(Foo.new().bar.x) -- prints 5. It's fine.

const Bar = <unutterable-2>

That is, what you do is group all classes into a mutually dependent block that can reference each other unconditionally, and then and only then do you incrementally bring the name of the individual class into scope.

The reason why hoisting in JS gets so much hate is because it relies on TDZ which cannot be analyzed at compile time, that's literally why TDZ exists at runtime. Here, we can warn when attempting to reference Foo that is not yet bound in scope. You have a warning, so this isn't hoisting in the same sense as JS hoisting.

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 8, 2026

Choose a reason for hiding this comment

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

That said, the RFC as is does phrase it like the solution is either-or: 1. no way for two classes to be defined out-of-order or mutually dependent in some way, or 2. do TDZ and say all class identifiers are hoisted to the top.

The solution I suggested avoids this problem, as long as classes do not have any initializers.

Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 8, 2026

Choose a reason for hiding this comment

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

Sigh. Flip the direction. Functions in class definitions can capture locals. TDZ.

class Bar
  function run_foo()
    Foo.print_x()
  end
end

Bar.run_foo()

local x = 5

class Foo
  function print_x()
    print(x)
  end
end

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

What you're describing is an explicit phrasing of exactly what the RFC proposes except that we lose the constness of the class object. Prior to the definition of ClassB, its name is in scope but has the value nil.

It's not the best but I don't see a viable alternative.

I also don't know if it's really all that bad: It only arises when code is interleaving class definitions and executing imperative actions at the module scope.

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.

...So class variables are effectively globals?

If that's the case, how do shadowing and global variables behave with class definitions?

local A = 1 -- shadows A?
print(A) -- 1 or nil?
class A end
print(A) -- class A?

B = 2 -- global variable or parse error from reassignment?
print(B) -- 2 or nil?
class B end
print(getfenv()["B"]) -- 2?

Or what if the module returns early before a class is initialized?

class A
	function f()
		return B {} -- 50/50 chance always nil?
	end
end

if math.random(0, 1) == 1 then return end

class B end

Even if a set of consistent rules can be made, it just seems like a lot of footguns are going to be caused by this.


Static analysis also considers the class's type to be global to the whole module so that it can appear in any type annotation anywhere in the script.

An example:

```luau
-- illegal: MyClass is not yet defined
local a = MyClass {}

-- OK: MyClass can appear in any type annotation anywhere
function use(c: MyClass)
end

function create()
-- OK as long as this function is invoked after the class definition statement
return MyClass {}
end

-- We can't statically catch this in the general case, but this will fail at runtime!
create()

class MyClass
end

local b = MyClass {} -- OK
local c = create() -- OK
```

Because class definition is a statement, class methods can capture upvalues just like ordinary functions do.

```luau
local globalCount = 0

class Counter
public count: number

function new()
local count = globalCount
globalCount += 1
return Counter {count=count}
end
end
```

### Out of scope for now

#### Private fields, const fields

These are things we want to do, but integrating them with the existing structural type system is surprisingly tricky and will be tackled in separate RFCs.

#### Generic classes

We're very excited to support generic classes and plan to introduce a fresh RFC to deal with them specifically.

#### Inheritance

We're still evaluating whether or not implementation inheritance is something we want to support.
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.

Seems to not be possible to me. Suppose:

class A
  x: number
  y: number
end

class B extends A
  x: string
end

There's nothing you can do to populate the field x with both number and string. This is possible in Java and C# only because you have a type system that directly feeds into codegen, so if you have a binding of type B, you know it's B.x, not A.x, and if you have A, you know it's A.x.

This tells me that inheritance is pretty much impossible to implement in a way that is actually sound in Luau.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is this not a problem simply solvable with types? I would expect B to be allowed to add any new methods or members it wants, but when redefining it must match or narrow. That's not something currently easily expressed in the type system right now with table types, but on paper I don't think there's anything that would prevent it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Shadowing is certainly a huge problem that we'd have to solve. Inheritance is complex enough that it merits an RFC all by itself.


Method inlining is something we'd like to try that is greatly complicated by inheritance.

Also, frankly, its worth as a programming technique is controversial: the [Fragile Base Class Problem](https://en.wikipedia.org/wiki/Fragile_base_class) can cause significant harm to a project.

Lastly, while Luau doesn't quite properly afford interface inheritance through its structural type system, this shortfall is relatively easy to fix. Because of this, implementation inheritance is judged to be lower priority.

## Drawbacks

This is a really big feature that has lots of moving parts!

We need to introduce multiple new contextual keywords: `class` and `public` to start and `private` later. We also introduce two new top types `object` and `class`.

Allowing code to grab unbound method references (ie `local m = o.someMethod`) seems risky because it opens the doorway to a lot of difficult-to-optimize dynamism, but it also makes a bunch of nice things like `pcall` work exactly the way developers expect. We're making the bet here that this does not materially affect our ability to optimize more mundane attribute access or method calls.

The word `class` is doing triple duty under this RFC: It is a contextual keyword, the name of a top-level library, and the name of the top type for class objects.

Object oriented codebases tend to have far more cyclic dependencies between modules because every piece of data is also coupled to a whole bunch of functions that operate on that data. We are probably going to have to work out a way to relax the restrictions on cyclic module imports.

## Alternatives

[Arseny's record proposal](https://github.com/luau-lang/luau/blob/7f790d3910bbfc2adf007da3551b0a13e42ebb7a/rfcs/records.md). This proposal is really quite similar, but looks a bit more familiar to users coming from other languages and affords the development of features like private fields.

[Shared Self Types](https://github.com/luau-lang/rfcs/blob/master/docs/shared-self-types.md). This proposal was intended to shore up table type inference in the case that the code was written in an OO style, but after significant work, it doesn't actually work all that well in practice. The resulting system was very brittle and tricky to work with. Trickier, in fact, than the pattern that developers are already writing today.

### Field syntax

We considered a number of possibilities for field syntax before settling on `public` and `private`.

Type annotations must be optional, so the syntax we choose must work well in that situation.

We considered using the existing `local` keyword for class fields, but judged it to be a bridge too far: Fields are not locals! They do not inhabit the stack.

We also considered using no keyword at all and judged that to be unacceptable in the case that a field also has no type annotation. A single bare identifier on a line all by itself looks too weird and might make the grammar difficult to change later on.

So there must be a keyword and it cannot be `local`. Other keywords we considered were `var`, `let`, and `field`.

```luau
class Test
local foo

public foo
public foo2: number

private local foo2: number
private foo3: number

var bar
var bar2: number

field bar
field bar2: number

quux
quux2: number
end
```

`let` is a great keyword to introduce a local binding, but does not make a ton of sense in a class declaration. (especially a declaration where we’re not providing an initializer!)

`var` is pretty good and also makes sense as a token to introduce a local, but is largely redundant.

`field` is spiritually aligned with the syntax sensibilities that drove the development of Lua, but has no precedent in any other language.

`public` and `private` work pretty well from a parsing perspective, have no historical precedent in Lua, and also encode ideas that we definitely want classes to support.