Guide to Type-Checking with OOP

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.

8 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

4 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)

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

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

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

3 Likes

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

1 Like

I omitted it for simplicity of the example but it’d look something like this if I wrote them:

type PrivateVariables = {
    name: string -- yippee! type safety 
}

loca privateVariables: { [Something]: PrivateVariables } = {}

local Something = {}
Something.__index = Something

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

    self.nothing = nil

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

type Something = typeof(Something.new(…)) -- right under new, before methods

function Something.doSomething(self: Something): ()
    privateVariables[self].name = 2 -- type warning! i think… i’m on mobile so i can’t test it
end

return Something
5 Likes

nice tutorial. Is there a way to access created types from modules? I use module.Type and it sort of works but I can access things like .__index

Adding export before a type makes it accessible under the namespace the module gets required by.

-- Module.luau
export type MyType = number
return nil

-- Server.luau
local MyModule = require(Module.luau)
local A: MyModule.MyType = 1

Unfortunately having .__index exposed is a side effect of capturing the whole class table as a metatable.

However, you can set Car.__index to just a new table and then put the methods inside it. It wastes more memory, but it fixes your problem.

local Car = {}
Car.__index = {}

-- Same Stuff...

function Car.__index.Boost(self: Car): ()
	self.Speed += 50
end
1 Like

guess I gotta cope lol. anyway thanks