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.

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

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

im having issues with this. I cant tell whether:


is supposed to be a new module or inside of this module. it says a new module, but what do i do with that module? i have tried various ways and __index still shows the original __index, not a never type.

Could is be because I am using vscode with rojo for development, or am i doing something wrong?

Also, is there a way to hide the methods when requiring the module? Is there a way to implement getter and setter methods here?

I agree with other people that luau needs an actual class structure, getters and setters are just so useful.

Not sure if you missed this part

1 Like

oh it just looks so similar at a glance, thank you for clarifying.
Might wanna put a comment in that code block pointing out the change so other people as slow as me can clearly see it.

1 Like

Also, have you found a way to implement getters and setters with this style of OOP? it seems pretty powerful on its own, but i dont think ill be able to replace my current system unless this one can have getters and setters. That is the only issue with the way I curently do them, that it cant do that with autofill.

I don’t usually do private variables in class instances, so I can’t say I have a special way of implementing getters and setters.

ive been working on it, and i found a way. It does like to throw type checking errors a lot, but they are easily fixed by using type assertion on whatever errors (::). I think the errors are caused by the linting method with ClassInstance.

The implementation i have uses the runtime type checking module (t) but it is possible without it.

local ReplicatedStorage = game:GetService("ReplicatedStorage")

local t = require(ReplicatedStorage.Packages.t)

local CustomInstance = {}
CustomInstance.__index = CustomInstance

local setters: { [string]: (CustomInstance, string, any) -> never } = {}
local getters: { [string]: (CustomInstance, string) -> ...any } = {}

function setters.SETTERNAME(self: CustomInstance, index: string, newValue: *NEWVALUETYPE*)
--note that you dont need to do any type checking for newValue in here as long as
--you configured SetValueExpectedInterface in CustomInstance.__newindex properly
--it is all handled there rather than the setters to save precious lines

--IMPORTANT:
--make sure to use rawset() in setters, otherwise it might cause strange looping behavior
end

function getters.GETTERNAME(self: CustomInstance, index: string)
--there is no type checking to be done here; there is no setValue

--IMPORTANT:
--like above, use rawget() to avoid strange behavior
--I am actually unsure if rawget and rawset are needed, but better be safe than sorry
end

local _ClassInstance = setmetatable({ __index = nil :: never }, CustomInstance)
local _CustomInstanceLinted = nil :: typeof(_ClassInstance) & typeof({ __index = nil :: never })

type CustomInstanceData = {
	PropertiesList: { string },
	--everything else normal here
}

export type CustomInstance = typeof(_CustomInstanceLinted) & CustomInstanceData

CustomInstance.__index = function(self: CustomInstance, index: string)
	if CustomInstance[index] then
		return CustomInstance[index]
	elseif getters[index] then
		return getters[index](self, index)
	elseif (self :: {})["__" .. index] then
		return (self :: {})["__" .. index]
	end
	error(debug.traceback("Property " .. index .. " not found in " .. tostring(self)))
end
CustomInstance.__newindex = function(self: CustomInstance, index: string, setValue: any)
	local propertyIndex = table.find(self.PropertiesList :: { string }, index)
	if not propertyIndex then
		error(debug.traceback("Property " .. index .. " not found in " .. tostring(self)))
	end

	local SetValueExpectedInterface = t.interface({
		ClassName = t.string,
		PublicProperty = t.number,
		PropertiesList = t.map(t.number, t.string),
	})

	local success, error = SetValueExpectedInterface(setValue)
	if not success then
		error(debug.traceback(error))
	end

	local oldValue = (self :: {})["__" .. index];
	(self._ChangedSignals :: {})[index]:Fire(oldValue, setValue)

	local setter = setters[index]
	if setter then
		setter(self, index, setValue)
		return
	end

	rawset(self, "__" .. index, setValue)
end

function new(): CustomInstance
	local self = setmetatable({}, CustomInstance) :: CustomInstance

	--TODO: add all private/internal properties with __ before them

	self.PropertiesList = {
		--all properties listed here as strings
	}

        --[[
        if you want a changed signal implementation, then add self._ChangedSignals and do the following:
	self._ChangedSignals = {}
	for _, propertyName in self.PropertiesList do
		self._ChangedSignals[propertyName] = Instance.new("BindableEvent").Event
	end
        ]]
	return self
end

function CustomInstance.dosomething(self: CustomInstance)
--we have to use . notation and explicitly declare self for autofill down here
--not too bad, my old OOP metaphor also had this drawback. I think it is easier to read like this too
end

return new

I havent tested this code anywhere but in the autofills, and the only issue is that __newindex is accessible from each instance created. this is probably because CustomInstance.__newindex is defined as a function rather than a table, so the linting method you showed doesnt wipe it for soem reason.