Skip to content

Classes!!#191

Open
andyfriesen wants to merge 12 commits intomasterfrom
syntax-classes
Open

Classes!!#191
andyfriesen wants to merge 12 commits intomasterfrom
syntax-classes

Conversation

@andyfriesen
Copy link
Copy Markdown
Collaborator

Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated

Within a class block, two declarations are allowed: Fields and methods.

Fields are introduced with the new `public` keyword.
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.

Understand the need for a new keyword but public strongly suggests we are going to do private therefore should that be included as part of the RFC? (Just the what, not the how)

Copy link
Copy Markdown
Collaborator Author

@andyfriesen andyfriesen Apr 6, 2026

Choose a reason for hiding this comment

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

I suppose I could have done that, but I decided to leave it out because private has some very finnicky and complicated nuances to it with respect to how it behaves in code that lacks annotations.

There will be a followup RFC for private fields and methods.

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 we need to explain how it works just that we intend to do it (and we'll figure out how later)

Comment thread docs/syntax-classes.md

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.

Comment thread docs/syntax-classes.md Outdated

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.

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.
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've always interpreted type to be the safe one that tells me the true type and typeof to be the one that might lie to me about the actual type. Any reason we wouldn't also do that here? Alternatively, what if typeof returned as a second argument, the pointer to the class?

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.

The main problem I want to really address is that type and typeof both work with strings.

Class names aren't required to be globally unique, so you could get wedged into a bad situation if a class happens to be unfortunately named.

instanceof is designed to solve this problem by working with class objects directly.

Copy link
Copy Markdown
Contributor

@bradsharp bradsharp Apr 6, 2026

Choose a reason for hiding this comment

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

I'd like to avoid code where I am mixing typeof, instanceof, and getmetatable (for table-based classes). Maybe it's not actually that bad in practice - but it would be nice to have a single method that tells me about somethings type.

Additionally, it can be nice to use these methods to 'inspect' the type. With instanceof I must enumerate all possible types the object can be. Sometimes I might want to write:

local supportedTypes = {
  [FooClass] = true,
  [BarClass] = true,
  [BazClass] = false,
}

-- Version I'd like to write
function isSupportedType(object)
  local T = typeof(object) -- May not be the exact statement you'd write
  return supportedTypes[T]
end

-- Version I'd need to write with current proposal
function isSupportedType(object)
  for T, supported in supportedTypes do
    if instanceof(object, T) then
      return supported
    end
  end
end

Copy link
Copy Markdown
Collaborator

@vegorov-rbx vegorov-rbx Apr 7, 2026

Choose a reason for hiding this comment

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

I've always interpreted type to be the safe one that tells me the true type and typeof to be the one that might lie to me about the actual type.

This is incorrect, neither type nor typeof can lie about the type name for performance and embedder sandboxing.

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.

Also, let's be honest. I don't think the code in the example is particularly great. Just write the return instanceof(o, FooClass) or instanceof(o, BarClass) or .... Dumb code is good code.

Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md

```luau
class Point
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.

Comment thread docs/syntax-classes.md
@Dekkonot
Copy link
Copy Markdown
Contributor

Dekkonot commented Apr 6, 2026

The existence of a new VM type raises a question for the type checker. Obviously, class MyClass end will give us a type named MyClass but will there be a new built-in object type?

I imagine the use would mostly be for inverting type requirements to accept anything that wasn't an object (for e.g. serializing data) which might be niche but it's still worth considering in my opinion.

Comment thread docs/syntax-classes.md Outdated
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 that contains initial values for all the fields.
Copy link
Copy Markdown
Contributor

@bradsharp bradsharp Apr 6, 2026

Choose a reason for hiding this comment

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

Are we going to optimize calls in the form Class { Field = "Blah" } (also with parens) so that they don't construct a table unnecessarily?

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 necessarily for V1, but it's a good idea.

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.

This is a bit similar to the construction problem in the records RFC. It seems to me the only way you can ever enforce the zero cost constructor is to make the constructor private to the module at minimum. Otherwise if you export the class, every function calls with f { x1 = x1, ..., xn = xn } is potentially a class constructor or a normal function call, and breaks formatting, and also raises a weird question like what should these do:

local xy = {x = 1, y = 2}

apply(Point)(xy)

Point(xy)

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.

The idea would be that the class constructor always exists as a real function you can call, but we provide a peephole optimization in the specific case of ConcreteClass { props ... } that inlines it and eliminates the temporary table.

@deviaze
Copy link
Copy Markdown
Contributor

deviaze commented Apr 6, 2026

They can only be written at the topmost scope.

Why limit class bindings to top-level when they're not exported? I've wanted a lightweight class abstraction in a local function before.

Local, function, and type bindings can be defined in any scope, therefore it'd make sense to allow the same for classes.

@TenebrisNoctua
Copy link
Copy Markdown

Certainly an interesting RFC so far. A couple of questions:

Do we need access specifiers in Luau? Majority of the code written today do not need private fields, and since classes can capture up-values, do we truly need private fields? And since private fields are not going to be supported out of the box, it seems a little weird to include the public keyword at the moment. Perhaps for now, Luau can treat a field as public by default, and if an RFC for private passes, one could explicitly define public or private.

Why create a whole new type in the VM? Couldn't a syntax sugar be implemented in place for table objects with a metatable instead? It seems unnecessary to me.

Also, I believe inheritation will be important, as at the moment, you can already create classes with a lot of functionality and decent support for the type-checker (except for shared-self types, which should be coming eventually!), but when it comes to inheritation, things get rather difficult.

@bradsharp
Copy link
Copy Markdown
Contributor

@deviaze would you be able to give an example of this?

I've wanted a lightweight class abstraction in a local function before.

@deviaze
Copy link
Copy Markdown
Contributor

deviaze commented Apr 6, 2026

@bradsharp

@deviaze would you be able to give an example of this?

I've wanted a lightweight class abstraction in a local function before.

Sure!

Here, the Dependency class is only relevant within its enclosing method (and would be a lot more readable as a class here).

https://github.com/deviaze/touchpaddy2/blob/025f0db3a7962f3297cae1817e618f12ffd7e951/src/platform.luau#L47

Comment thread docs/syntax-classes.md Outdated
@lewisakura
Copy link
Copy Markdown

This doesn't seem to be stated in the RFC, but if public is the only privacy modifier right now and private is coming later, what will the default privacy be in the future when private does get added?

@cheesycod
Copy link
Copy Markdown

What does the C API look like for classes?

Comment thread docs/syntax-classes.md
```luau
class Point
public x: number
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.

@Bottersnike
Copy link
Copy Markdown

Just had a quick skim, but I noticed __index isn't allowed. Is there any provision for __index-like behaviour (maybe exclusively if it doesn't match any defined fields+methods)?

Comment thread docs/syntax-classes.md

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, Luau easily supports interface inheritance through its structural type system, so inheritance is judged to be lower priority.
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.

Luau does not support interface inheritance through the structural type system for any type that has methods because method types are not expressible in the type system. Examples:

type interface = { foo: (self: interface) -> () }

type something = { value: number, foo: (self: something) -> () }

local function fn(obj: interface)
	obj:foo()
end

local function create(): something
	return {
		value = 0,
		foo = function(self: something)
			print(self.value)
		end,
	}
end

fn(create()) -- type error!
type interface = { foo: <self>(self: self) -> () }

type something = { value: number, foo: (self: something) -> () }

local function fn(obj: interface)
	obj:foo()
end

local function create(): something
	return {
		value = 42,
		foo = function(self: something)
			print(self.value)
		end,
	}
end

fn(create()) -- type error!
type interface = { foo: <self>(self: self) -> () }

local function fn(obj1: interface, obj2: interface)
	obj1.foo(obj2) -- runtime error!
end

There are other avenues, but I believe I have explored them all, and it seems that there is no way to express interfaces with methods in Luau in a way where the type system gives no false positives and where it also catches common runtime errors. And this is without getting into generic interfaces.

Comment thread docs/syntax-classes.md Outdated

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

We need to introduce multiple new keywords: `class` and `public` to start and `private` later.
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.

Probably worth noting that these are contextual keywords - they'll still be able to be used as identifiers.

Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated
Comment thread docs/syntax-classes.md Outdated

#### 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.
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.

Are class objects values? Can they be passed around? What is their type? What happens if you pass a class object into instanceof or type?

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.

Class objects are values that mostly behave the same as class instances.

They are not considered to be members of any class type, so instanceof will always return false.

This could change later if we wanted to introduce a type hierarchy a la Python.

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.

Should instanceof(MyClass, class) return true (assuming we introduced a class lib)

@jackdotink
Copy link
Copy Markdown
Contributor

Looks pretty great, just some details that need to be ironed out in the RFC process!

@ishtar112
Copy link
Copy Markdown

ishtar112 commented Apr 6, 2026

I'm on board insofar as polishing OOP in Luau, but some of the specifics of this specific proposal leave me feeling a bit iffy.

The initial lack of support for private fields and methods is disappointing — understandable from the perspective of how the RFC describes the complications, but disappointing nonetheless. My concern with its omission pertains largely (if not entirely) to syntax and readability. Assuming users do not subscribe to prefixing bindings with _ to denote privacy, the lack of support for private members is likely to bring about a new category of visually messy code that the RFC otherwise does very well to amend.

There is "tension" between the syntax for fields and methods that we could honestly do better about. If functions are public by default, why do fields need to be declared with access modifiers? I'm also of the opinion that field should be a mandatory part of declaring fields: public field foo feels more in line with public function foo(), and, in combination with fields hypothetically no longer needing to be declared with an access modifier, field foo would feel more in line with function foo(). It seems an interesting decision that the keyword field is described as a mutually exclusive alternative to public/private instead of as something that works with public and private like function is already intended to.

@deviaze
Copy link
Copy Markdown
Contributor

deviaze commented Apr 6, 2026

I don't think public nor private by themselves are great indicators of something being a field. It might be more clear to C++ or C# users, but I feel it'd be quite unfamiliar to Luau users.

Even if a field keyword doesn't have prior art in other languages, it makes more sense to include it for readability reasons:

class Cat
    field name: string
    private field internal_id: number
    
    function meow(self, text: string)
    end
end

In my opinion, class fields without a private qualifier should be public by default since that's the default for fields in a Luau table.

@Ukendio
Copy link
Copy Markdown

Ukendio commented Apr 6, 2026

I find the rationale on implementing visibility modifiers being in the vein of "may as well because we have to introduce some keyword so it doesn't look weird" to be very weak. This just seems like a very bad way to decide on language features.
Just a suggestion that was not mentioned but an unannotated field could just be t1: _ or if you are going to separate fields by whitespace in the grammar anyways, just allow it to be blank such as t2: . Or why not simply enforce they have to use types? We are talking about a feature being implemented post-type-system, I don't see why the language has to bend over backwards for people who don't want to use the strict type system.

Back on visibility modifiers, I think that if the goal is to avoid unwanted access, then there are better ways that already exist at the API or representation by putting that state behind handles, whether they are opaque references or simple indices into other structures. There does not need to be some keyword that adds a whole charade to how that data is being accessed that can ultimately be bypassed ostensibly.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Certainly an interesting RFC so far. A couple of questions:

Do we need access specifiers in Luau? Majority of the code written today do not need private fields, and since classes can capture up-values, do we truly need private fields?

Roblox has, in the past, released APIs written in Luau using conventions like _foo to denote private fields. People inevitably write code that directly accesses those private fields and now they are essentially part of the public API even though they were never meant to be.

Something more robust is needed. I'm working on a separate RFC to get into the details of this.

Why create a whole new type in the VM? Couldn't a syntax sugar be implemented in place for table objects with a metatable instead? It seems unnecessary to me.

We think there's a performance win to be had here: Class instances don't need the array part of a table, and we think that we can save some memory and improve cache locality by splitting the property hash table. The class object holds the set of keys in a particular class. The class instances just hold an array of values.

Also, I believe inheritation will be important, as at the moment, you can already create classes with a lot of functionality and decent support for the type-checker (except for shared-self types, which should be coming eventually!), but when it comes to inheritation, things get rather difficult.

Shared-self unfortunately did not work out. I spent a couple of months trying to implement it, and it turned out to be a lot more brittle than I had hoped. I cover this briefly in the RFC.

@TenebrisNoctua
Copy link
Copy Markdown

Roblox has, in the past, released APIs written in Luau using conventions like _foo to denote private fields. People inevitably write code that directly accesses those private fields and now they are essentially part of the public API even though they were never meant to be.

I still don't think this issue necessitates the implementation of access specifiers. Like I mentioned, an upvalue system could just be preferred instead.

class A
    function new()
         local privateField = 0
         local object = A()
         object.getter = function()
             return privateField
         end
         return object
    end
end

I believe a solution like this would be a better workaround. Of course, if private fields could be optimized somehow by the compiler, many people would be more okay with it.

That aside, I do believe the reasoning as to why public and private exists is weak. No keyword looks great, and if we're still going to have keywords for fields, then it should be made with a keyword like "field".

Shared-self unfortunately did not work out. I spent a couple of months trying to implement it, and it turned out to be a lot more brittle than I had hoped. I cover this briefly in the RFC.

That really sucks. Custom class modules perhaps would have benefitted from it. However, native classes in Luau sounds and works as a better option. I do look forward to potential optimizations, describing some of them would give this RFC a better foundation to work with.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Why limit class bindings to top-level when they're not exported? I've wanted a lightweight class abstraction in a local function before.

This restriction could be lifted someday. It's in place now just to keep things simple.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Just had a quick skim, but I noticed __index isn't allowed. Is there any provision for __index-like behaviour (maybe exclusively if it doesn't match any defined fields+methods)?

This is probably okay to add at a later date. It requires some extra complexity in the VM, but it should be fine.

@alexmccord
Copy link
Copy Markdown
Contributor

alexmccord commented Apr 9, 2026

Will classes have builtin __eq implementations? How would the class itself (not an object instance of the class) be represented as a type?

... snipped ...

but having class.classof without being able to refine on class would be kinda useless statically.

The class types doesn't need __eq implementation. It's going to be compared by identity, and class types are heap objects as well (its lifetime is the maximum lifetime of all object whose class.classof is the class plus any other strong references). They have to be GC objects because of generativity (if classes could be instantiated multiple times at runtime, obviously, which it cannot under the current restrictions).

As for refinements using class.classof(o) == Cat, this is possible if Luau team chooses to author the code that pattern matches on that syntax, but I think class.isinstance(o, Cat) is much better for performance reasons (the former would be LOP_FASTCALL1 + LOP_JUMPIFEQ + LOADB, the latter is just LOP_FASTCALL2). So I'd rather have people be pushed to use class.isinstance instead.

@alexmccord
Copy link
Copy Markdown
Contributor

alexmccord commented Apr 9, 2026

One point I have not seen discussed yet is the inclusion of a type annotation on the second parameter of __add in the example. As the metamethods are known to be static, does this mean we will be able to type check operator overloading on classes?

You're already able to do this. That's how Luau can infer the result of overloaded operators, e.g. cf * v infers the type Vector3 whereas cf * cf2 infers the type CFrame, and any other inputs are an error.

Further to this point, a class.getfields method would be useful for introspection. "class" is already covered by class.classof, and it’s unclear what practical use a methods list would have. However, having a list of field names would allow inspection of unknown classes at runtime and remove the final need for a generalised iterator over classes. The method could even return a table representation of the class without methods or a metatable which would allow direct use within for loops.

for fieldName, fieldValue in class.getfields(unknownObject) do
    print(fieldName, fieldValue)
end

Iterating over a class is actually ill-defined at a fundamental level if fields can be private. In one scope, iterating over a class should result in public fields only, in another scope it should result in public and private fields... which is nonsense, I've never seen any language construct that depends on the scope in this way.

Looks like JavaScript just says that iterating over the fields of a class with private fields do not ever yield the private fields, even if that private field is in scope.

I think this would also squash any remaining reasons to want to iterate over a class.

...

However, having a list of field names would allow inspection of unknown classes at runtime and remove the final need for a generalised iterator over classes.

Neither of these two are true at all. If you have HashSet or HashMap classes or any container-y thing, you want to implement __iter.

The better solution is for the class type to contain the information you want. Not the object.

Comment thread docs/syntax-classes.md

Methods are introduced with the familiar `function` keyword. `public function f()` is also permitted.

If a method's first argument is named `self`, it can be invoked with the familiar `instance:method()` call syntax. Type annotations on the `self` parameter are not allowed.
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 implies that every function in the class whose first argument is self has to check assert(class.isinstance(self, Self)).

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 implies that every function in the class whose first argument is self has to check assert(class.isinstance(self, Self)).

You shouldn't be able to call a method that takes self with the wrong type of object. Luau should probably throw a runtime error if that happens

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.

We've put a ton of thought into this kind of thing. If we walk down this path, it will be a more general typechecking mode where all annotations are validated at runtime rather than having one magic special case just for the type of self.

For this iteration, the type of self will always be statically resolved as that of the enclosing class, but it will be unchecked at runtime just like any other ordinary type annotation you might write.

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.

That's kind of disappointing, I think, because that means the compiler has to generate pessimistic branch for self in classes.

@Cooldude2606
Copy link
Copy Markdown

Looks like JavaScript just says that iterating over the fields of a class with private fields do not ever yield the private fields, even if that private field is in scope.

That feels like my expectation, as the use case would be for handling an unknown class, and if it is within the class itself then it isn't unknown. So there would be no reason to iterate for introspection, meaning no reason to ever include private fields.

Neither of these two are true at all. If you have HashSet or HashMap classes or any container-y thing, you want to implement __iter.

Sorry should have been more specific, when I said "generalised" I meant "generic". i.e. an iterator which applies to all classes by default that do not have a customer iterator defined by __iter.

Comment thread docs/syntax-classes.md
## 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).

Comment thread docs/syntax-classes.md
@SeloTheGreat
Copy link
Copy Markdown

SeloTheGreat commented Apr 9, 2026

Could we get more clarification on how the following piece of code would behave?

class listener
   --does this field count as a method?
   --are dynamically assigned functions in objects forbidden?
   --or... am i blind and this was clarified in the rfc?
   public callback: (self) -> ()

   function new(callback: (self) -> ())
      return listener { callback = callback }
   end
end

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Could we get more clarification on how the following piece of code would behave?

There are no special restrictions on class fields that are functions. I think your code would be fine.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

Here's a crazy bikeshed idea: make class definitions mirror function definitions like Kotlin and require parentheses around the fields. This actually makes using public for functions possible since it can't be confused with a field!

class Point(
	public x: number, -- private when no modifier, can introduce others like const and read
	public y: number
)
	-- can put public before functions with no ambiguities
	public function getLength(self): number
		return (self.x ^ 2 + self.y ^ 2) ^ 0.5
	end
	
	-- metamethods must be public
	public function __add(self, other: Person): Person
		return Point(self.x + other.x, self.y + other.y) -- alternate constructor ?
	end
end

To me, this looks much more Luau-y and solves a few potential ambiguities. It also has obvious syntax extensions for generics and inheritance/mixins.

class Tree<T>(nodes: {T})
end

class BinaryTree<T>(nodes: {T}): Tree<T>
end

The only catch is this syntax implies the constructor also mirrors a normal function call like Point(1, 2), which in my opinion would actually be better since the current table constructor requires speculative compiler heroics to reach acceptable performance (heroics which may not be applicable across module lines).

@InfraredGodYT
Copy link
Copy Markdown

InfraredGodYT commented Apr 10, 2026

Here's a crazy bikeshed idea: make class definitions mirror function definitions like Kotlin and require parentheses around the fields. This actually makes using public for functions possible since it can't be confused with a field!

To me, this looks much more Luau-y and solves a few potential ambiguities. It also has obvious syntax extensions for generics and inheritance/mixins.

The only catch is this syntax implies the constructor also mirrors a normal function call like Point(1, 2), which in my opinion would actually be better since the current table constructor requires speculative compiler heroics to reach acceptable performance (heroics which may not be applicable across module lines).

This syntax actually reminds me a lot more of that awkward ability to create classes with functions in JavaScript.

// From https://learn-js.org/en/Object_Oriented_JavaScript because its the first thing I thought of
function Person(firstName, lastName) {  // why is this even a thing YOU HAVE A CLASS KEYWORD
    // construct the object using the arguments
    this.firstName = firstName;
    this.lastName = lastName;

    // a method which returns the full name
    this.fullName = function() {
        return this.firstName + " " + this.lastName;
    }
}

const myPerson = new Person("John", "Smith");
console.log(myPerson.fullName());            // outputs "John Smith"

I'd much rather have classes look like a regular block as proposed by the RFC. It not only fits with Luau's/Lua's syntax much more, but it also looks cleaner than the Kotlin-like example in your comment.

class Car(public model: string, public make: string, public year: number, public speed: number, public engineType: string)

I doubt people would want to write code like this.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

@InfraredGodYT Here's my rationale for the parentheses:

  1. Many object-oriented languages (Kotlin, Scala, C#) support similar syntax for primary constructors, and since the current semantics are the class generates a default constructor from the fields, it makes sense to mirror this.

  2. Lexically separating the fields from the methods makes it easier to add new contextual modifiers and syntax without introducing ambiguities like the following:

class Example
	public private
	function f() end -- private function or field named private?
	
	public x =
	public y -- public x = public; y?
	
	function g() end
	public z = g() -- is this legal?
end
  1. It makes sense to treat fields/functions without public as private by default, so adding a new private keyword isn't required. Having no keyword behind fields also doesn't look weird since having no keyword behind parameters is normal.

  2. You can't have your cake and eat it to: either have nice constructor syntax with terrible performance or good performance with bearable syntax (which you won't even use that much considering most classes will handroll their own constructors like the RFC examples).

For your example, why can't you just indent the fields like you normally would with a long function definition or table? This is one of the benefits of Lua(u) being whitespace insensitive.

class Car(
	public model: string,
	public make: string,
	public year: number,
	public speed: number,
	public engineType: string
)
end

@Pyseph
Copy link
Copy Markdown

Pyseph commented Apr 10, 2026

This is a welcome direction. The metatable OOP pattern has been one of the sharpest paper cuts in the language for years, and the less-than-hoped-for delivery of shared-self types makes a first-class construct the obvious path forward.

Most of the feedback in this thread has focused on individual design decisions, and I think that's all been productive. What I want to step back and ask about is the layering strategy, because I think it's where the biggest risk lives.

This RFC defers private fields, generic classes, inheritance, default values, static members, and scoped classes. Each of those is reasonable to defer on its own. But collectively, they mean that a large number of the decisions being baked in now are load-bearing predictions about designs that haven't been worked out yet. The public keyword is the clearest case: it exists partly to prepare for private, but the RFC also says private fields have complicated nuances that need a separate RFC. If that future design doesn't land the way we expect, or takes a different shape, public becomes a vestigial keyword that every class has to carry for no reason.

Luau has historically been good about this kind of caution. zeux's Records RFC arguably erred too far toward minimalism, but its instinct was sound: commit to as little as possible in v1 so that extensions don't have to work around early decisions. I think this RFC would benefit from the same instinct in a few places. Concretely, choosing field over public isn't just about readability; it's about not committing to an access modifier vocabulary before the access modifier system exists. Dropping hoisting isn't just about avoiding a footgun; it's about not painting yourself into a corner where classes must be top-level because of an invariant that might not be worth preserving.

Then again, I am vastly outside my comfort zone here; if any of this feedback is unsound or unwarranted noise, please take it with a large grain of salt. I bear no experience in such low-level engineering, so I only hope to offer my two cents on the development strategy of the RFC as it goes past its MVP stage.

@alexmccord
Copy link
Copy Markdown
Contributor

alexmccord commented Apr 11, 2026

I realized something that's going to be extremely annoying. In Lua, tables whose fields are nil is equivalent to just not existing, unless it turns out that the Luau VM keeps the field around despite being nil (plausible, why bother changing the shape if it shrinks).

For example, if a name does not have a middle component, that's still a valid Name, but Name { first = "Foo", middle = nil, last = "Baz" } might not be able to instantiate an instance of Name due to the field middle. Yet I want Name { first = "Foo", last = "Baz" } to be an error.

class Name
  first: string,
  middle: string | {string} | nil,
  last: string,

  function __tostring(self)
    local s = self.first

    s ..= if self.middle == nil then ""
      elseif typeof(self.middle) == "string" then " " .. self.middle
      elseif typeof(self.middle) == "table" then " " .. table.concat(self.middle, " ")
      else absurd(self.middle)

    return s .. " " .. self.last
  end
end

local names = {
  { first = "Foo", middle = "Bar", last = "Baz" }, -- valid
  { first = "John", middle = nil, last = "Doe" }, -- valid
  { first = "Jane", last = "Doe" }, -- invalid?
}

for _, name in names do
  print(tostring(Name(name)))
end

What's your plan on this?

@jackdotink
Copy link
Copy Markdown
Contributor

I think that there are many forward-looking things, and it may be best to delay general release until classes are considered to be in a complete state, so that things can be changed as needed without introducing breaking changes.

@TenebrisNoctua
Copy link
Copy Markdown

This is a welcome direction. The metatable OOP pattern has been one of the sharpest paper cuts in the language for years, and the less-than-hoped-for delivery of shared-self types makes a first-class construct the obvious path forward.

Most of the feedback in this thread has focused on individual design decisions, and I think that's all been productive. What I want to step back and ask about is the layering strategy, because I think it's where the biggest risk lives.

This RFC defers private fields, generic classes, inheritance, default values, static members, and scoped classes. Each of those is reasonable to defer on its own. But collectively, they mean that a large number of the decisions being baked in now are load-bearing predictions about designs that haven't been worked out yet. The public keyword is the clearest case: it exists partly to prepare for private, but the RFC also says private fields have complicated nuances that need a separate RFC. If that future design doesn't land the way we expect, or takes a different shape, public becomes a vestigial keyword that every class has to carry for no reason.

Luau has historically been good about this kind of caution. zeux's Records RFC arguably erred too far toward minimalism, but its instinct was sound: commit to as little as possible in v1 so that extensions don't have to work around early decisions. I think this RFC would benefit from the same instinct in a few places. Concretely, choosing field over public isn't just about readability; it's about not committing to an access modifier vocabulary before the access modifier system exists. Dropping hoisting isn't just about avoiding a footgun; it's about not painting yourself into a corner where classes must be top-level because of an invariant that might not be worth preserving.

Then again, I am vastly outside my comfort zone here; if any of this feedback is unsound or unwarranted noise, please take it with a large grain of salt. I bear no experience in such low-level engineering, so I only hope to offer my two cents on the development strategy of the RFC as it goes past its MVP stage.

I do agree with the argument that public and private should be put away for a future RFC. All fields would be public by default and when private is introduced, the public keyword can be used alongside it.

@neopolitans
Copy link
Copy Markdown

neopolitans commented Apr 11, 2026

I think in regards to public/private, the keywords aren't needed right now as local already exists. I respect and love the idea of adding them, but it adds on keywords that are specific to one programming pattern, rather than for the whole language.

Adapting local in the scope of classes to refer to a class property* is up to the Luau Language Contributors, but adding two new keywords right now doesn't seem like an answer to more than just helping those of us familiar with C/C++/C#.

@Pyseph
Copy link
Copy Markdown

Pyseph commented Apr 11, 2026

Wanted to follow up on the field keyword question specifically. The RFC dismissed local because “fields are not locals, they do not inhabit the stack,” but I think that reasoning optimizes for theoretical correctness over practical familiarity.
Nobody writing local x = 5 thinks about register allocation. They think “I’m introducing a named thing in this scope.” Luau already stretches local across different storage mechanisms silently: a local at top scope is a register; a local captured by a closure becomes a heap-allocated upvalue. The keyword has always meant “here is a binding,” not “here is a stack slot,” and users have never needed to care about the difference. The average familiarity of local among Luau developers is extremely high; field and public both start from zero.
Inside a class body, local reads as “here is a binding that belongs to this class”:

class Cat
    local name: string
    local lives: number

    function meow(self)
        print(self.name .. " says meow")
    end
end

No new keywords at all; it composes with const if that ever applies to fields, and when private arrives, private local name: string parallels private function naturally. The access modifier qualifies the declaration rather than being the declaration, which avoids the vestigial-public problem I mentioned earlier.
A concern is that people might expect local inside a class body to mean a class-scoped temporary rather than a field. But that use case doesn’t exist today, and could be handled with a different syntax if it ever comes up.​​​​​​​​​​​​​​​​

Comment thread docs/syntax-classes.md
Comment thread docs/syntax-classes.md

```luau
local class: {
isinstance: (o: unknown, C: class) -> boolean,
Copy link
Copy Markdown
Contributor

@alexmccord alexmccord Apr 11, 2026

Choose a reason for hiding this comment

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

Let's take this one out? It's backward compatible to retrofit the o is C notation as long as the expression on the right after is is an identifier-starting token, and you even get a guarantee that o is C could lower to one instruction that:

  1. If o is an object, load the class of o.
  2. If the class of o is equal to C, jump to the consequent (no need to assert whether C is a class).
  3. Otherwise, C could possibly be a non-class. If it is not a class, throw an error.
  4. Otherwise, jump to the alternative.

This would mean the compiler can avoid generating the pessimistic branch where class is not the built-in class library and the assert function is not the built-in assert function. Then something like this is as tight as possible.

export function foo(o: MyClass)
  if not o is MyClass then -- maybe we should add `o is not MyClass`?
    -- adding a `return` here to prevent a join in the codegen's CFG
    return error(`expected {MyClass} but got {typeof(o)}`)
  end

  -- now the compiler knows it can generate specialized code for `o`
  return o.foo
end

export class MyClass
  foo: number,
  bar: number,
end

@bytejon
Copy link
Copy Markdown

bytejon commented Apr 12, 2026

I agree again that local should be used for private field modifiers. And no public keyword at all! It makes it sound "local to the class context" which would be true from an encapsulation stance. Being part of the class itself (static) is where additional syntax keywords would likely be needed, and that's fine.

In my opinion, matching this new system to existing language aesthetics would be a positive! Most everything else in Luau is "public-like" so that should be the default "quickest to achieve". Then, local can be layered atop and it feels intuitive.

@gaymeowing
Copy link
Copy Markdown
Contributor

I think that there are many forward-looking things, and it may be best to delay general release until classes are considered to be in a complete state, so that things can be changed as needed without introducing breaking changes.

Agreed, they should be behind a FFlag that has to be manually enabled at first. Alongside a studio beta for Roblox, rather than just pushing them into production immediately after they're implemented according to this RFC.
Allowing for amendments to be made after people get to play around with classes, that don't need to worry about backwards compatibility.

@andyfriesen
Copy link
Copy Markdown
Collaborator Author

Here's a crazy bikeshed idea: make class definitions mirror function definitions like Kotlin and require parentheses around the fields.

We've talked about field declaration syntax internally a lot. This is one of the things we talked about. We decided not to do it because it's a little bit too off-the-beaten-path and doesn't have much of an upside to the current syntax.

This RFC defers private fields, generic classes, inheritance, default values, static members, and scoped classes. Each of those is reasonable to defer on its own. But collectively, they mean that a large number of the decisions being baked in now are load-bearing predictions about designs that haven't been worked out yet. The public keyword is the clearest case: it exists partly to prepare for private, but the RFC also says private fields have complicated nuances that need a separate RFC. If that future design doesn't land the way we expect, or takes a different shape, public becomes a vestigial keyword that every class has to carry for no reason.

I take your point, but you can see that classes has the potential to become a massive feature! We need to strike a balance: On one hand, we need to constrain the discussion space so that we can productively make decisions. On the other, the final feature really should look like a unified whole rather than the sum of a series of hedged bets.

On the topic of private specifically, Roblox really does need a robust way to defend against Hyrum's Law. It already has problems with the camera and control scripts where developers have taken advantage of fields that are only private-by-convention.

For example, if a name does not have a middle component, that's still a valid Name, but Name { first = "Foo", middle = nil, last = "Baz" } might not be able to instantiate an instance of Name due to the field middle. Yet I want Name { first = "Foo", last = "Baz" } to be an error.

There's no solution here. If you wish to initialize a nilable field with nil, omitting the key has to be a valid way to do so.

I think that there are many forward-looking things, and it may be best to delay general release until classes are considered to be in a complete state, so that things can be changed as needed without introducing breaking changes.

Not to worry. I think so too. This one needs a bit of hands-on-keyboard time to surface practical issues that might not be immediately apparent.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

The only reason I'm adamant about the parentheses is because unlike conventional OO languages, the fields of Luau classes are the constructor and have to be initialized. The standard syntax for fields doesn't reflect that since most languages like C# or JavaScript just treat their fields as zero-initialized/undefined until the constructor assigns a new value to them. Luau classes don't have the same mechanics, so the syntax is just a bad fit.

Mirroring a function call with the prior art of "default constructors" makes it very clear that fields are also the constructor and have to be initialized with something. It also neatly separates the instance fields from the class body, which opens up the possibility of adding other constructs like static fields to the class body easily:

class Person(name: string, id: number)
	local currentId = 0 -- static field
	
	function new(name: string)
		currentId += 1
		return Person(name, currentId)
	end
end

I'd like to see how static fields could be done in a cleaner way than this.

@MagmaBurnsV
Copy link
Copy Markdown
Contributor

@alexmccord Also, just saying that making the constructor a normal function call solves your issue with missing keys since function arity isn't affected by nil:

local x = Name("Foo", "Bar", "Baz") -- valid, 3 args
local y = Name("John", nil, "Doe") -- valid, 3 args
local z = Name("Jane", "Doe") -- invalid, 2 args

@Cooldude2606
Copy link
Copy Markdown

I like the idea behind having fields "like function parameters" but do not like ordered parameters for construction. I have found these to be quite fragile when working with classes with a large number of fields, and always resulted in me creating a "from_table" static method.

It would be interesting syntax, possibly to extend to functions, where they can be defined using {} which is sugar for having a single table argument with the given keys. This mirrors how {} can call a function with the first argument as a table.

If that sounds like a useful extension then we could at first use () for classes, and later introduce {} for both classes and functions.

Mostly just food for thought.

function foo{ name: string, reps: number | nil }
    for i = 1, reps or 1 do
        print("Hi " .. name)
    end
end

class Cat{ name: string }
    function speak(self)
        print(self.name)
    end
end

foo{ name = "Alice", reps = 5 }
local bob = Cat{ name = "Bob" }

@Rob07erto
Copy link
Copy Markdown

I feel like () this should be the case

class Cat( name: string )
function speak(self)
print(self.name)
end
end

function foo( name: string, reps: number | nil )
for i = 1, reps or 1 do
print("Hi " .. name)
end
end

And luau do a python with named variables

foo( name = "Alice", reps = 5 )
local bob = Cat( name = "Bob" )

Comment thread docs/syntax-classes.md
* `__newindex`
* `__mode`
* `__metatable`
* `__type`
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.

Would it make sense to reserve all method names starting with a double underscore (__)? That would ensure that if we introduce new metamethods in the future they can't possibly conflict with user code.

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.