Guide to Type-Checking with OOP

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.

62 Likes

Hi,

I found a lot of this extremely helpful for setting up type checking in OOP inheritance headache-inducing situations and I’ve figured most of it all out because of this post, thank you.

If I require the FullClassModule and attempt to access a variable from the SuperClass module, for example:

local myClass = require(script.Parent.Class)
print(myClass.new().Value) --> has a warning but still prints 1! (As the value given in your example)

Everything in the print statement is underlined in orange with an extremely long warning but when the code is ran it does infact print 1 without any errors. Is there a way to prevent these warnings?

Edit: It’s really strange because the Lua typechecker or whatever has no problem getting the :Method() from the base (super) class but it can’t seem to get its values?

Edit 2: For any future lurkers, I’ve figured it out? Following this example, in the Class module type export declaration, change export type self = typeof(setmetatable({}, SuperClass)) & ... to export type self = typeof(Superclass.new()) & ...

Do I know what this change did? No. All I know is the warning is gone and I can type check with ease.

2 Likes

So, I have a more up-to-date idiom that I got to work, but I just haven’t gotten around to updating the post.

Here’s the updated idioms:

Car
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
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: Truck = setmetatable(Car.new() :: any, Truck)
	
	self.Gas = 200
	self:Refill()
	
	return self
end

function Truck.Refill(self: Truck): ()
	self.Gas += 50
end


setmetatable(Truck, Car)

return Truck

I̶’̶l̶l̶ ̶u̶p̶d̶a̶t̶e̶ ̶w̶h̶e̶n̶ ̶I̶ ̶g̶e̶t̶ ̶t̶h̶e̶ ̶t̶i̶m̶e̶.̶
Edit: Updated!

4 Likes

Why not just do this instead of inserting self: Car into every function’s parameters?

local Car = {} :: Car
Car.__index = Car

type self = {
	Speed: number
}

export type Car = typeof(setmetatable({} :: self, Car))

And then you can have : methods as usual.

function Car:Boost(): ()
	self.Speed += 50 -- self.Speed is typed
end
2 Likes

In strict mode, you can’t add fields to an ascribed table without it flaring.

local t = {} :: {a: number}
t.b = 2 -- bad!

Meaning Car.__index, Car.new and every method will raise an error.

6 Likes

Any workaround to do private fields and private methods?

1 Like

None currently, besides just denoting them with _ or overriding the table type with a fake interface.
Maybe with readonly properties, but that’s a long way off.

1 Like

Doing underscore still makes the fields and methods visible.

--!strict

export type Animal = {
    Name: string,

    Greet: (self: Animal) -> nil,
} & any

local Animal = {}
Animal.__index = Animal

function Animal.new(name: string): Animal
    local self = setmetatable({}, Animal) :: Animal
    self.Name = name
    self._age = 2 -- Hidden
    return self
end

function Animal._jump(self: Animal) -- Also hidden
    -- Jump here
end

function Animal.Greet(self: Animal)
    print(self.Name, "says hi!")
end

---------------------------------------------------------------------------------
export type Dog = Animal & {
    Greet: (self: Dog) -> nil,
}

local Dog = setmetatable({}, Animal)
Dog.__index = Dog

function Dog.new(name: string): Dog
    local self = setmetatable(Animal.new(name) :: any, Dog) :: Dog
    self._age += 2 -- Should be 4, right?
    return self
end

function Dog.Greet(self: Dog)
    -- Overriding the Greet method from Animal class
    -- The Greet method from Animal could still be accessed by doing
    -- Animal.Greet(self)
    self:_jump() -- Will not be shown but exists
    print(self.Name .. ", jumping, says hi!")
end

Here, by doing export type Animal = {} & any you allow the table to have private fields without getting an error or exposing them on the type itself.

This is the best workaround I could find so far. If you have suggestions, please, let me know!

Best regards,
Octonions

2 Likes

Very nice guide, thank you very much

1 Like

If anyone wants a terser automatically typed way to write classes, I’ll leave this here:

--!strict
local Something = {}
Something.__index = Something

function Something.new(isSomething: boolean): Something
	local self = setmetatable({}, Something)

	self.isSomething = isSomething

	return self
end

export type Something = typeof(Something.new(...))

-- methods go here (they don't get typed correctly above the type export)
function Something.becomeNothing(self: Something): ()
	self.isSomething = false -- type is inferred from the new function
	self.isSomething = 1 -- TypeError: Type 'number' could not be converted into 'boolean'
end

return Something

Class method parameters also get correctly typed. I’m not sure why having the functions after the type export works but before doesn’t, it does work though.

The export-after-methods issue

What I mean by “methods don’t get correctly typed above the type export” is that methods above the type export don’t even end up existing in the type, even though they get auto-completed. I’ve only tested and seen this with Luau LSP, not the Roblox Studio script editor, though that’s likely an issue there too.

local Foo = {}
Foo.__index = Foo

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

	self.fuelTank = FuelTank.new(100)
	self.engine = Engine.new()

	return self
end

function Foo.start(self: Foo): ()
	self.engine:start()
end

export type Foo = typeof(Foo.new(...))

local fooObject = Foo.new()
fooObject:start() -- TypeError: Type 'Foo' does not have key 'start'

return Foo

The tradeoff: it doesn’t support inheritance

This approach is suited for composition or just not using any form of class co-dependence at all, see the below example.

Composition example

I always use composition over inheritance for multiple reasons, but the fact that we can get automatically generated types like this is a really convincing bonus (for me anyway!):

FuelTank class
local FuelTank = {}
FuelTank.__index = FuelTank

function FuelTank.new(litres: number): FuelTank
	local self = setmetatable({}, FuelTank)

	self.litres = litres

	return self
end

export type FuelTank = typeof(FuelTank.new(...))
Engine class
local Engine = {}
Engine.__index = Engine

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

	self.started = false
	
	return self
end

function Engine.start(self: Engine): ()
	self.started = true
end

export type Engine = typeof(Engine.new(...))
local Car = {}
Car.__index = Car

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

	self.fuelTank = FuelTank.new(100)
	self.engine = Engine.new()

	return self
end

export type Car = typeof(Car.new(...))

function Car.start(self: Car): ()
	self.engine:start()
end

return Car

It just works.

Code_PaHkEeEC5k


Bonus: VS code class snippets for this approach
"Class": {
		"prefix": "class",
		"body": [
			"local ${1:Foo} = {}",
			"${1:Foo}.__index = ${1:Foo}",
			"",
			"function ${1:Foo}.new($2): ${1:Foo}",
			"	local self = setmetatable({}, ${1:Foo})",
			"",
			"	$3",
			"",
			"	return self"
			"end",
			"",
			"export type ${1:Foo} = typeof(${1:Foo}.new(...))",
			"",
			"${0:-- functions go here (they don't get typed correctly above the type export)}",
			"",
			"return ${1:Foo}"
		]
	},
	"Class Method": {
		"prefix": "method",
		"body": [
			"function ${1:ClassName}.${2:doSomething}(self: ${1:ClassName}$3): ${4:()}",
			"	$0",
			"end"
		]
	}

(Put in your user snippets for lua)

5 Likes

When using this method to typecheck classes, it works perfectly inside of the class which the type is being created for. Once I use the exported type, I get some weird behavior.

More specifically, when I first set the type to a local variable, I get complete auto-filling for said class:

Once you call a method from the class, you no longer get auto-filling for your variable with the class its been set to, as seen below:

I’m not sure if this tutorial was made for exporting the type outside of the module which the class is being created inside of, but I tried it anyways and ran into this behavior. If anyone can help me prevent this from happening, I’d greatly appreciate it.

Even if this cant be fixed, thanks for the tutorial and this is still a very useful tool!

1 Like

Nice! I do prefer composition so I might refer to this later when I decide to switch to type checking OOP

2 Likes

I’m having a hard time reproducing this. Is KillerObject a type defined in the file or is it supposed to be exported from someone else? Normally, exported types are given a namespace:

local Module = require(script.Module)

local Value: Module.Type = Module.new()

I noticed that in nocheck mode a bugged ghost type of the module’s name exists which can be confusing. I recommend trying strict mode to see if that’s what’s going on and changing it to KillerObject.KillerObject.

1 Like

If I understand the question correctly, yes. KillerObject is a type which is exported from a separate module and is imported through the way you specified. Just to be sure I’m not doing something wrong, heres a dumbed down class, but its the exact same way I wrote KillerObject.

Dumbed Down Class

local class = {} :: Type
class.__index = class
type self = {
    StringValue: string;
    BoolValue: boolean;
}

export type Type = typeof(setmetatable({} :: self, class))

function class.new() : Type
    local self = setmetatable({} :: self, class)
    self.StringValue = 'String'
    self.BoolValue = false
    return self
end

function class:Method()
    print('I\'m doing something')
end

edit*
I just tested something right now, and found out the reason why it’s doing this, at least on my end. So if you refer to my original screenshot, I set the method killerInteraction:GetBlockCount() to a variable. When its set to a variable, it disabled auto-filling for the rest of the function.

local function something()
    local variable = class:GetSomething() -- Would be auto-filled
   class:GetSomethingToo() -- Would NOT be auto-filled
end
class:GetSomething() -- Would be auto-filled
class:GetSomethingToo() -- Would be auto-filled
1 Like

You can have private fields by just doing

function Animal.new(name: string): Animal
    local self = setmetatable({}, Animal) :: Animal
    self.Name = name
     
    local age = 2 -- Hidden
    return self
end
2 Likes

You can’t access those local variables in the object’s methods.

2 Likes

You can make public functions from inside the object, which access the private variables.

1 Like

Not efficient. Instead of holding a method for many different objects you will be filling up the memory with things that could have been better described.

3 Likes

An alternative method I saw someone else suggest on the forum somewhere:

local Something = {}
Something.__index = Something

local privateVariables = {}

function Something.new(name: string): Something
	local self = setmetatable({}, Something) :: Something

	self.nothing = nil

    privateVariables[self] = {
        name = name,
    } -- tadaaaa
    
	return self
end

function Something.doAThing(self: Something): ()
    print("Doing a thing with", privateVariables[self].name)
end

Credit doesn’t go to me.

2 Likes

Where’s the type definition? Or am I just not understanding this right?

1 Like