Typechecking OOP code across scopes

Typechecking in Luau is incredibly powerful to communicate to yourself and other developers as to the relative intent of user data, which allows for faster development. Additionally, typechecking, when done correctly, can allow for powerful autofill.

Object-oriented programming in Lua is a strange concept, since it’s an abstract approach to a programming language not otherwise built for OOP. However, the generally accepted method of creating classes looks something like this:

local TestClass = {}
TestClass.__index = TestClass

function TestClass.new(Value1, Value2) -- Constructor
	local self = setmetatable({}, TestClass)
	
	self.Name = Value1
	self.Age = Value2
	self.PrivateThing = "buh"
	
	return self
end

function TestClass:DoThing(myNumber)
	return {
		foo = 5 + myNumber + self.Age
	}
end

return TestClass

By default, there is autofill only in some cases, and additionally, there is no typechecking for property and parameter clarification.

A strong typechecking start is by creating the class type, which looks like this:

type Class = typeof(setmetatable({}, TestClass))

This is scalable, meaning you can add property typechecking as well:

type Class = typeof(setmetatable({}, TestClass)) & {
	Name : string,
	Age : number,
}

From here, you have a great start to type checking and autofill, with a type that can be exported:

--!strict

local TestClass = {}
TestClass.__index = TestClass

type Class = typeof(setmetatable({}, TestClass)) & {
	Name : string,
	Age : number,
}

function TestClass.new(Value1 : string, Value2 : number) : Class
	local self = setmetatable({}, TestClass)
	
	self.Name = Value1
	self.Age = Value2
	self.PrivateThing = "buh"
	
	return self
end

function TestClass:DoThing(myNumber : number) : {foo : number}
	return {
		foo = 5 + myNumber + self.Age
	}
end

return TestClass

However, there are a couple of issues. For example, .__index is seen as a usable class value, private properties appear in autofill, and private properties show linter warnings within the class (screenshots below show these).

image image

So from here, let’s create a module that allows us to typecheck the class and the objects well. The module code should look like this:

-- typeof(metatable) gives the linter information about what methods are in your class.

-- unioning typeof({index = nil :: never}) tells the linter to separate methods and properties.

-- We also set __index in the type definition to a "never" type so that it is clear to the programmer
-- that it should not be used.

-- A combination of all of this only autofills class functions, object functions, and public properties

return function(Class)
	local ClassInstance = setmetatable({__index = nil :: never}, Class)
	return nil :: typeof(ClassInstance) & typeof({__index = nil :: never})
end

Then you can adjust your class type:

type Class = typeof(require(script.Class)(TestClass)) & {
	Name : string,
	Age : number,
}

Now, methods and properties are separated in autofill, __index clearly is listed as something not intended to be read, and private properties do not show. In this case, self.PrivateThing no longer auto-fills outside the example class scope:

image

That’s all! Good luck with your OOP-typechecking experience.

36 Likes

It really throws me off that Luau doesn’t have syntactic sugar for classes; considering the fact that so many games on this platform rely on them. Metatables require so much boilerplate code that just makes the entire code harder to read through, especially in larger codebases. I hope this changes one day.

Thanks for the tutorial though, it’s the best I found so far.

4 Likes

Luau doesn’t really need it honestly, if you want to avoid extra boilerplate, metatable magic, and get types on class instances by default then do this:

local function Class()
	local self = {}
	
	local PrivateVariable = "Help!"
	
	self.PublicVariable = "Hello!"
	
	local function PrivateMethod()
		print("I'm private!")
	end
	
	function self:PublicMethod()
		print("I'm public!")
	end
	
	return self
end

return Class

Then to use it:

local newClass = require(pathtoclass)

local instance = newClass()

print(instance.GlobalVariable)

instance:PublicMethod()

I’m surprised more people don’t use this style of OOP, lua even has this style of OOP as an example in their docs.

2 Likes

You’re right, but I still find this still undesirable as it’s still inconsistent. I prefer to write something like this as it’s easier to read the code.

public username = "Daw588"
private password = "password123"

public function resetPassword()

end

Compared to:

self.username = "Daw588"
local password = "password123"

function self:resetPassword()

end

Syntactic sugar is unnecessary but makes code easier to read by making it look prettier and saves some time when writing new code. Take decorators for example, second example is much easier to read compared to the first one, this is especially true if you want to have multiple decorators applied to one function.

local function myFunction()

end

benchmark(myFunction)

Or

@benchmark
local function myFunction()

end

Another example is map, although you could write your function, the map function in JavaScript reduces complexity of your code (look wise, not necessarily performance wise). It’s generally easier to read the second example compared to the first one, obviously this has some drawback when it comes to learning curve but once you’re experienced, it really helps, at least me.

const raw = [1, 2];
const double = [];

for (let i = 0; i < raw.length; i++) {
    double[i] = raw[i] * 2;
}

With map

const raw = [1, 2];
const double = raw.map(n => n * 2);

Even the arrow function (which is another syntactic sugar) allows me to have a nice inline function. Although, I see why Luau team would not add more syntax sugar as it would increase the overall language complexity.

Anyway, I might be wrong because of my bias towards syntax sugar, thus please correct me if true.

1 Like

This creates unwanted side effects, primarily performance issues. When you create a function to anonymize the instance, you reallocate memory for that class every time it’s constructed. Creating a module and constructing with metatables manages issues by keeping the class in the memory only one time.

You also miss out on class-wide methods that are separated from instance methods; in some cases, this can lead to really gross code.

4 Likes

I don’t find the performance hit to be substantial enough to use an alternative, as for class wide methods personally I would create a singleton instead, but I can see how that could be a downside for some.

1 Like

Isn’t this misusing the never type? And it is already clear that __index is not intended to be read.

The never type is reserved for userdata that should not be accessible or usable, as it is the lowest-level type; in other words, it is the polar opposite of unknown. Using that type clarifies the intent of the __index property.

What does the never or unknown type even do? How would a type be unknown?

Give this page a read:

2 Likes

Can you help explain something?

TestClass.__index = TestClass

type Class = typeof(setmetatable({}, TestClass)

Why is setting __index even needed? Once the prototype is set as metatable for the new instance, shouldn’t that be enough to ensure that a method call to the instance gets passed on to the prototype?

I see it on the luau and lua site, so I don’t doubt it is needed. It’s just that to me it looks like an incantation that doesn’t do anything. Like A=A. Is it because a lookup in the metatable always uses the special __index function rather than a “normal” table lookup?

Heya! Read the comments in the code.

I did. And again now. I see that you set __index to nil and cast it to never. What I do not understand is why setting __index is part of the prototyping idiom to begin with. :slight_smile:

No problems, though. I will experiment and see what works for me.

Setting __index to nil tells the linter that the methods are not connected to the properties by not directing back to the class table.