This tutorial has been updated on March 4th
Introduction
Hey! So, the Luau page on idiomatic OOP is a little…lacking, to say the least. So after pretty much a 5-month gap, I’ve composed and refined my method of typing Luau classes until it all fit together.
This tutorial goes over more about the internals of my idiom, but if you just want the examples, they are below. This expects you are at least somewhat familiar with OOP, which if you aren’t then I recommend you this fantastic tutorial!
Car (Singular Class)
local Car = {}
Car.__index = Car
type self = {
Speed: number
}
export type Car = typeof(setmetatable({} :: self, Car))
function Car.new(): Car
local self = setmetatable({} :: self, Car)
self.Speed = 100 -- Speed is recognized
self:Boost() -- Methods too!
return self
end
function Car.Boost(self: Car): ()
self.Speed += 50 -- self now is typed and recognizes Speed!
end
return Car
Truck (Inherited Class)
local Car = require(script.Parent.Car)
local Truck = {}
Truck.__index = Truck
type self = {
Gas: number
}
export type Truck = typeof(setmetatable({} :: self, Truck)) & Car.Car
function Truck.new(): Truck
local self = setmetatable(Car.new() :: Truck, Truck)
self.Gas = 200
self:Refill()
return self
end
function Truck.Refill(self: Truck): ()
self.Gas += 50
end
setmetatable(Truck, Car)
return Truck
How it Works
So, let’s break it down. In our Car class, we define a type that represents our class’s structure.
local Car = {}
Car.__index = Car
type self = {
Speed: number
}
But this only represents the properties of the class (Speed
), so we need to somehow also represent their relationship with Car. Since normal tables and tables with metatables are exclusive to each other, we have to hackily capture its type, so.
type Car = typeof( setmetatable({}, Car) )
But you’ll notice type Car
doesn’t have any of our properties we defined in self
. So, to get these properties, we will have to assert self onto the first argument of setmetable
export type Car = typeof( setmetatable({} :: self, Car) )
Boom! Now Car
is recognized as the type of a table with a self
type that has a metatable of Car. The reason why we use export
is because it allows us to get this Car type in any other script when we require it, which will be important with inheritance.
We can then write our constructor to reflect this type to get the same results.
function Car.new(): Car
local self = setmetatable({} :: self, Car)
self.Speed = 100 -- Speed is known and is a number
return self
end
Now we can define our methods. Since the implied self
argument with the colon syntax like below doesn’t type, we will have to annotate self another way.
function Car:Boost(): ()
self.Speed += 50 -- What's Speed?
end
We can switch out this colon syntax with dot syntax and define the type for self
like a normal argument. Luau will also recognize this and will allow using colon syntax to call the method like normal.
function Car.Boost(self: Car): ()
self.Speed += 50 -- We got Speed!
end
Violia! Now your class is typed!
How Inheritance Works
Now that we have our Car
class, what happens if we want to have a Truck
class that inherits from it? Well, to do this we’ll have to dive deeper into metatable-type magic.
First, we will write our class and self
type like normal. Let’s say we want to add the property Gas
.
local Car = require(script.Parent.Car)
local Truck = {}
Truck.__index = Truck
type self = {
Gas: number
}
Now we need to get the Truck
type, which is really just the Car
type and our metatable-set table type combined. We can achieve this using the &
operator.
export type Truck = typeof(setmetatable({} :: self, Truck)) & Car.Car
Now we write our constructor like normal, except we replace the first argument of setmetatable with Car.new()
and assert it with Truck, so we don’t get any type errors.
function Truck.new(): Truck
local self = setmetatable(Car.new() :: Truck, Truck)
self.Gas = 200
return self
end
We then can write our methods the same way.
function Truck.Refill(self: Truck): ()
self.Gas += 50
end
Finally, to get the methods of Car
into Truck
, we need to set Truck
's metatable to Car
. It is really important that we set it after we’ve defined the Truck
type because… well I’m not entirely sure. I assume since the typechecker analyzes from top to bottom that it fixes the bug where setmetatable doesn’t like setting metatables for tables with metatables already set, since tables and tables with metatables are, again, mutually exclusive.
Closing
So yeah, does your brain hurt? Mine does too from trying to write this like metatable type theory makes sense. I’ve definitely learned a lot since the first draft of this tutorial, and I hope you have too!
Feel free to ask any questions.