Skip to content
244 changes: 244 additions & 0 deletions docs/syntax-classes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
# 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 }
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 the constructor private to the class or to the module, or is it always public? Or is it the Rust rule: if all fields are public, the constructor is also public?

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 Rust rule.

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

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)

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.

There's another extension missing here: a way to let the read field be public and the write field be private, along with a way to overload the reader and the writer for the storage of the given field. Otherwise we end up permeating classes with Java's smelly point:getX() as opposed to C# properties.


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.


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.


If a method accepts no arguments, or if its first argument is not named `self`, it can be invoked via `ClassObject.method()` syntax. This is the same as "static methods" from other languages.

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 familiar Luau metamethods like `__add` and `__sub`. They will work as one would expect. `__index`, `__newindex`, `__mode` and `__metatable` may not be defined. Attempting to do so is a syntax error.
Comment thread
andyfriesen marked this conversation as resolved.
Outdated

#### 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`)

We introduce a new global function `instanceof(a, Class)` which returns `true` if the object `a` is an instance of `Class`. `instanceof` raises an exception if the second argument is not a class object. If the first argument is not a class instance, `instanceof` returns false. (eg `instanceof(5, MyClass)`)
Comment thread
andyfriesen marked this conversation as resolved.
Outdated
Comment thread
bradsharp marked this conversation as resolved.
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.


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

type(Cls) == 'object' -- Class objects themselves behave like class instances
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 does not seem right to me, it breaks the semantics of "classes" being a template that objects are instantiated from (but maybe that's just a wrong way of thinking from me)

typeof(Cls) == 'object'

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

#### Class Objects
Comment thread
bradsharp marked this conversation as resolved.
Outdated

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What's the rationale behind limiting class definitions to only module scope, and likewise

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

? I assume it's for performance? It strikes me as weird given all other language constructs live within scopes that can be smaller than a module, and allow shadowing/redefinition.

Is this a restriction that exists to simplify the current implementation, or something expected to persist? I'd be interested more explanation in the RFC justifying this restriction.

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.

I've added verbiage to the motivation section that explains this.


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


### 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 `instanceof` function participates in refinement:

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

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


## Drawbacks

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.


Allowing `ClassObject.someprop` 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.

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