Luau - Type friendly class syntax

Classes and types don’t mix well in Luau.

Types don’t have proper metatable support, and since the common Luau class syntax depend on them, issues appear left and right, unknown, unpredicted types, it’s painful.

The method for building classes was highlighted to me by @Elttob on Fusion Friday. I’ve known this method existed for a while, but it was unviable because of performance concerns.

Since then, Luau has introduced function “caching”, meaning if a function is the same and follows some rules, it will not waste more memory because of it.

What is this method?

This method doesn’t involve metatables at all. It simply just depends on the fact that this new function caching optimization now exists, which makes this much, much more viable.

This is how you will build a class using this method.

export type Class = {
    Public: number,
    Changed: ScriptSignal.ScriptSignal,
    _private: number,
    
    Add: (Class, number) -> ()
}

local function Class(): Class
    local self = {
        Public = 0,
        Changed = Signal(),
        _private = 0
    }

    function self:Add(number) -- Inferred!
        local result = self._private + number

        self._private = result
        self.Public = result

        self.Changed:Fire()
    end

    return self
end

One note is that creating new objects is slower.

Need to confirm: Indexing methods is faster because we store methods on the table itself, not having to pass through __index.

Anyway, this has been it. I found this method for creating classes really nice and wanted to show it off to other developers searching for type friendlier code!

It’s nice to highlight that Luau is thinking about getting class syntax which would solve the issues with types. Meanwhile, we have this, which is also a very elegant solution.

Note: You can wrap these functions in tables and call it .new if you wanna be consistent with Roblox’s APIs.

8 Likes

As you mentioned this way of doing it is slower. I make use of type casting for class typing. Heres a example:

type Class = {
    Public: number,
    Changed: ScriptSignal.ScriptSignal,
    _privat: number,
    -- Note how you need to pass a class type, this infers to self
    Add: (Class, number) -> nil
}

local Class = {}
local CLASS_META_TABLE = { __index = Class }

function Class:Add(number)
    local result = self._private + number

    self._private = result
    self.Public = result

    self.Changed:Fire()
end

local function CreateClass(): Class
    local self = setmetatable({
        Public: 0,
        Changed: Signal(),
        _privat = 0,
    }, CLASS_META_TABLE) :: Class
    return self
end
1 Like

There are other considerations here - the OP’s way of building objects avoids metaprogramming and so should in theory be much more idiomatic and type-friendly. Not everything requires millisecond level optimisation and this is a massive foot gun that even I still make a lot of the time.

2 Likes

While that does reduce memory significantly, each table requires an extra entry for each method, so more memory is still required over the metatable equivalent. For example, here’s a type with 3 fields and 5 methods:

Functions:

type Vector3 = {
	X: number,
	Y: number,
	Z: number,
	Magnitude: (self: Vector3) -> (number),
	Unit: (self: Vector3) -> (Vector3),
	Lerp: (self: Vector3, v: Vector3, alpha: number) -> (Vector3),
	Dot: (self: Vector3, v: Vector3) -> (number),
	Cross: (self: Vector3, v: Vector3) -> (Vector3),
}

local function newVector3(x: number, y: number, z: number): Vector3
	local self = {X=x, Y=y, Z=z,
		Magnitude = nil,
		Unit = nil,
		Lerp = nil,
		Dot = nil,
		Cross = nil,
	}
	function self:Magnitude(): number
		return math.sqrt(self.X*self.X + self.Y*self.Y + self.Z*self.Z)
	end

	function self:Unit(): Vector3
		local m = self:Magnitude()
		return newVector3(self.X/m, self.Y/m, self.Z/m)
	end

	function self:Lerp(v: Vector3, alpha: number): Vector3
		return newVector3(
			(1-alpha)*self.X + alpha*v.X,
			(1-alpha)*self.Y + alpha*v.Y,
			(1-alpha)*self.Z + alpha*v.Z
		)
	end

	function self:Dot(v: Vector3): number
		return self.X*v.X + self.Y*v.Y + self.Z*v.Z
	end

	function self:Cross(v: Vector3): Vector3
		return newVector3(
			self.Y * v.Z - self.Z * v.Y,
			self.Z * v.X - self.X * v.Z,
			self.X * v.Y - self.Y * v.X
		)
	end
	return self
end

local m = gcinfo()
local N = 1000000
local t = table.create(N)
for i = 1, N do
	t[i] = newVector3(1,2,3)
end
print(gcinfo()-m)

Metatable:

type Vector3 = {
	X: number,
	Y: number,
	Z: number,
	Magnitude: (self: Vector3) -> (number),
	Unit: (self: Vector3) -> (Vector3),
	Lerp: (self: Vector3, v: Vector3, alpha: number) -> (Vector3),
	Dot: (self: Vector3, v: Vector3) -> (number),
	Cross: (self: Vector3, v: Vector3) -> (Vector3),
}

local Vector3 = {__index={}}

local function newVector3(x: number, y: number, z: number): Vector3
	local self = setmetatable({X=x, Y=y, Z=z}, Vector3)
	return self
end

function Vector3.__index:Magnitude(): number
	return math.sqrt(self.X*self.X + self.Y*self.Y + self.Z*self.Z)
end

function Vector3.__index:Unit(): Vector3
	local m = self:Magnitude()
	return newVector3(self.X/m, self.Y/m, self.Z/m)
end

function Vector3.__index:Lerp(v: Vector3, alpha: number): Vector3
	return newVector3(
		(1-alpha)*self.X + alpha*v.X,
		(1-alpha)*self.Y + alpha*v.Y,
		(1-alpha)*self.Z + alpha*v.Z
	)
end

function Vector3.__index:Dot(v: Vector3): number
	return self.X*v.X + self.Y*v.Y + self.Z*v.Z
end

function Vector3.__index:Cross(v: Vector3): Vector3
	return newVector3(
		self.Y * v.Z - self.Z * v.Y,
		self.Z * v.X - self.X * v.Z,
		self.X * v.Y - self.Y * v.X
	)
end

local m = gcinfo()
local N = 1000000
local t = table.create(N)
for i = 1, N do
	t[i] = newVector3(1,2,3)
end
print(gcinfo()-m)

Results:

Implementation Entries Memory
Functions 3+5 320312 KB
Metatable 3 195312 KB

The functional version of this particular example is using about 1.64x more memory. The exact difference depends on the total number of entries per table, and how tables are allocated. As another example, 3 fields and 1 method wont have any difference, because 3 and 3+1 entries produce the same table size.

5 Likes

That’s quite nice to note, forgot to mention that, I’ll be adding to the topic.

The problem with using the class syntax you showed, using metatables, has the issue that it just doesn’t annotate everything. It’s not as type friendly as this method.

Example:

function Class(or __index):method(parameter)
    -- self is any, not the specific Class type
    -- parameter is not inferred either
end

Which means you need to have repeated annotations, which isn’t great.

Self is more problematic here because annotating it makes your code uglier.

I’ve found ways to work with type checking on Classes, but they’re messy, they don’t infer everything they should, as Luau has no understanding of Classes, and it’s just painful.

This is the best method I’ve found because it solves all these issues or conflicts you would find using metatables, at the cost of creating an object being more expensive. (Both memory and speed wise)

To me, I don’t really create, allocate any memory every frame or anything. So this becomes pretty insignificant, as most objects are created when the game launches.

If I was to, then I would sacrify type safety for performance in that case by using metatable-based classes, (so if I had to make a Vector3 class), but for most cases, this method works better, makes developing faster, easier, and just nicer.

@HawDevelopment, the code you passed has the same issues. (@Anaminus the code you passed just like his, would warn or somewhat just not do what you told it do on the type system)