There is a common idiom followed by developers when implementing the object-oriented paradigm in Luau, it looks as follows:
local class = {}
class.__index = class
function class.new(x: number, y: number)
local self = setmetatable({}, class)
self.x = x
self.y = y
return self
end
function class:addXY()
return self.x + self.y
end
return class
This is a standard example of a class in Luau. For the purpose of the example, the class takes 2 numbers as arguments upon initialization and exposes a method to add those 2 numbers together. You would use the class like so:
local object = class.new(5, 34)
local result = object:addXY()
print(result) --> 39 (5 + 34)
This is how we should be creating classes:
local class = {}
class.interface = {}
class.schema = {}
class.metatable = {__index = class.schema}
function class.interface.new(x: number, y: number)
local self = setmetatable({}, class.metatable)
self.x = x
self.y = y
return self
end
function class.schema.addXY(self: class)
return self.x + self.y
end
type class = typeof(class.interface.new(table.unpack(...)))
return class.interface
The problem with the previous idiom
There are several flaws I have identified with this idiom, I’m going to go over each individually then I will explain how I’ve overcame these issues. You will notice that the object initialized from the class has several useless type inferences that really should not be exposed to the programmer.
I will briefly explain each issue below, then I will go over how to fix them.
Issue 1 - .new().new()
.new()
is the constructor and should not be listed as a member of the object!
Issue 2 - .__index
It is not obvious to me why the developer would ever need type suggestions for the existence of .__index in their objects. To remove this issue, it is as simple as explicitely defining the __index in the metatable rather than in the main class’s table. This will be shown in later examples.
Issue 3 - No type inference when writing methods of class
When writing the functionality of your class, you are offered no type suggestions about the object itself.
It only shows me the attributes and methods its inferred from the __index, leaving me in the dark about what self
actually contains. In the case of this example, it should also show me the existance of the attributes x
and y
, but it does not!
So how do we solve these issues
local class = {}
class.interface = {}
class.schema = {}
class.metatable = {__index = class.schema}
As you can see my class definition is split into three subcategories:
interface - the table which is exposed to the end-user of the class. It will often include the constructor of the class
schema - the __index of the initialized objects, often containing methods/common values/etc. The constructor included in the interface will return an object which has it’s __index set to this table. Meaning the object will have access to all members of this table.
metatable - In my original post, the metatable was defined in the constructor as local self = setmetatable({}, {__index = class.schema})
. Someone mentioned there was a performance overhead to creating a new metatable on each construct. To solve that issue I’ve just described the metatable in the main scope, so it is created once, and re-used for all object instantiations. This still gets rid of Issue #2!
Because the interface and schema are two seperate tables, this solves the issue where the constructor was inferred as part of the object.
function class.interface.new(x: number, y: number)
local self = setmetatable({}, class.metatable) --> defining __index rather than in the class solves issue #2
self.x = x
self.y = y
return self
end
Due to the fact that the constructor now exists in the interface, the object returned from said constructor will have an __index
that has no knowledge of the existence of interface
as it is it’s own unique table, effectively removing the .new().new()
issue.
Now let’s define our method:
function class.schema.addXY(self: class)
return self.x + num
end
Now, you will notice a few things here. One, I did not use the typical :
operator when defining a method. Instead, I used .
and explicitly defined self as the first argument. This is precisely what :
was already doing, except in my case I want to do it manually in order to assign the class
type to self. This is what gives me type inference when writing the functionality of my class.
As you can see, when writing code inside of a method, I can see exactly what methods and values exist within the object. This is because I’ve constructed a type called class
which you can see being assigned to the first parameter of the method self
. You’ll also notice the autocomplete suggestions don’t include the previously listed code smells. Here is how it is created:
type class = typeof(class.interface.new(table.unpack(...)))
You might be confused as to why I included table.unpack(...)
. This is because you will get a warning in your script editor when you do not explicitly define each individual required argument expected to be passed to the constructor. You can manually define these by hand, or use table.unpack(...)
as a shortcut to handle the error. Keep in mind that if your constructor has no required arguments, you should not include any arguments in the definition of the type at all otherwise you will also get an error.
Voila!
We’ve fixed these issues of the original idiom and developed a new one that is even more powerful!
Old:
New:
Not only does the object look much cleaner to the end-user, but I also have the added benefit of type inference when writing inside of my methods as shown above. In my opinion this as far more powerful to design classes with this idiom.