Object Oriented Programming with Luau in 2023

We often write classes in Luau, but since types came out, it became difficult to get type checking right without complex workarounds. Today, I wanted to share one of the class method writing methods that I started using recently.

--!strict

type Impl = {
	__index: Impl,
	new: (name: string, balance: number) -> Account,
	deposit: (self: Account, credit: number) -> (),
	withdraw: (self: Account, debit: number) -> (),
}

type Proto = {
	name: string,
	balance: number
}

local Account: Impl = {} :: Impl
Account.__index = Account

export type Account = typeof(setmetatable({} :: Proto, {} :: Impl))

function Account.new(name: string, balance: number)
	local self = setmetatable({} :: Proto, Account)
	self.name = name
	self.balance = balance
	return self
end

function Account:withdraw(debit): ()
	self.balance -= debit
end

function Account:deposit(credit): ()
	self.balance += credit
end

return Account

If you don’t like so many types inside your class module, you can put them in a separate module, eg. local Types = require(script.Types).

21 Likes

I know this isn’t a tutorial, but can you explain what you did with the types (I understood the function part).

Although I am not the creator of the code, from what I observed myself:

  • Impl type deals with the general structure of the class, like the constructor, and the methods.
  • Proto type deals with properties of the class.
  • Account type wraps Impl and Proto types in metatable since classes need metatables to work.
1 Like

Ah, thanks. Haven’t created classes in a while so I got confused myself :sweat_smile:

1 Like

By the way, one last question.

Doesn’t it define function type? Also, you’re not returning anything.

The () is kind of an equivalent to void, it acts as, but I unsure whether this is official or just a hacky workaround to having void type. It essentially means that you’re not returning anything.

Does this clean up the autocomplete from showing a bunch of backend stuff? Never really liked this behavior.

Also are there any other benefits than the above?

2 Likes

Unfortunately not, but hopefully Roblox will decide not autocomplete things that start with _ outside of the module where it was defined.

Not that I know of, it just gives you proper type checking for everything in the class.

2 Likes

This is an inefficient way to type-check all functions and values of a class. It leads the programmer to constant incorrect type checks.

I suggest using the following approach:

--!strict
local Account = {
    name = "Account",
    balance = 0
} 

Account.__index = Account
export type Account = typeof(Account)

-- Removed types 'Impl' and 'Proto'
-- Now, the type 'Account' itself will automatically infer all types and values of the 'Account' class, eliminating the need for manual type inference.

function Account.new(name: string, balance: number): Account
    local self = setmetatable({}, Account)
    self.name = name
    self.balance = balance
    return self
end

function Account:withdraw(debit: number): nil
    self.balance -= debit
end

function Account:deposit(credit: number): nil
    self.balance += credit
end

return Account

But why?
In this code, the engine will automatically infer all types (functions and values, including new ones) of the Account class, eliminating the need for manual type inference. This makes it easier to adjust and maintain the code, especially in larger projects.

However,
When creating inherited functions, you must define them using the two-point syntax. If you don’t, the type engine will not recognize them as inherited functions.

function Class:Name()
– Implicit self, correct way.
– Recognized as an inherited function.
– Two point syntax

function Class.Name(self)
– Explicit self, incorrect way.
– Not recognized as an inherited function.
– One point syntax

Class.Name = function(self)
– Explicit self, incorrect way.
– Not recognized as an inherited function.
– Variable syntax

4 Likes

I found a better method, although it requires explicit self.

--!strict

local Account = {}
Account.__index = Account

type Properties = {
	name: string,
	balance: number
}

function Account.new(name: string, balance: number)
	local properties: Properties = {
		name = name,
		balance = balance
	}
	return setmetatable(properties, Account)
end

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

function Account.withdraw(self: Account, debit: number)
	self.balance -= debit
end

function Account.deposit(self: Account, credit: number)
	self.balance += credit
end

return Account
3 Likes

Definitely is also an alternative way to achieve automatic type detection; however, it is useless in cases where the class lacks a creation/replication function.

what does Impl stand for here? Implementation? I’ve seen a lot of class modules use this style, all using the term Impl in them. It seems like a really abstract idea, having separate types for the ‘properties’ container and the ‘methods’ container, although it is a lot more explicit. Is the goal to explicitly define every piece of data in the script?

To be fair, I’m new to using strict type-checking, so I don’t know its limitations. Perhaps this really is the best way to do classes in LuaU, but that would just speak further to how Lua at its core was not designed for classes like this, and trying to do it in LuaU is sort of band-aidy. What is the reason someone would have to be so clear, defining types for the separate properties and methods containers?

1 Like

The term most likely comes from Rust’s impl keyword which stands for implement/implementation. There are only structs and associative methods in Rust.


You will hear a lot about how Lua is a multi-paradigm language with support for procedural, functional and objective due to how extensive it can get with tables and metaprogramming.

But, I personally disagree with that notion. Lua/Luau is not an object-oriented language, but prototype-based. You don’t inherit or encapsulate tables, you construct them.

And that’s actually really powerful when you combine it with semantic scoping, type-checking and pure functions. That account class can actually just become:

type Account = {
	Name: string,
	Balance: number
}

local function DepositAccount(self: Account, Amount: number)
	self.Balance += Amount
end

local function WithdrawAccount(self: Account, Amount: number)
	self.Balance -= Amount
end

Notice how orthogonal everything becomes. It also maps better for performance!

Modern objective programming also favors sharing state which requires working through a “web” of classes. I recommend this video and its follow-up to learn more about that.

4 Likes

Although it does get rid of the typical boilerplate of implementing strictly-typed classes, it comes with a mild inconvenience: static members.

What’s more, any class that derives from Account will also have to deal with this. It also allows Account.withdraw(Account). At the end of the day, it’s just a mild inconvenience, but it’s an inconvenience nonetheless.

I can see where you’re coming from, but, um, actually :nerd_face:, it’s yet another OOP thing, as AccountImpl is simply the base class of all Account’s. This implies that AccountImpl is essentially the implementation of Account, so we name it accordingly (it’s nothing more, nothing less).

This is literally C. I don’t hate it, although I think it would’ve been more natural for you to do

type Account = {
    name: string,
    balance: number
}

local Account = {}

function Account.new(name: string, amount: number): Account
    return {
        name = name,
        amount = amount
    }
end


function Account.Deposit(account: Account, amount: number)
    ...
end

...

You can’t even complain. We use this syntax all the time, such as with string.len, table.insert, string.match, string.format, just to name a few.

P.S. this comes with a limitation, though: it isn’t OOP. We can’t even have virtual methods!

1 Like
--!strict

type Impl = {
	__index: Impl,
	deposit: (self: Account, credit: number) -> (),
	withdraw: (self: Account, debit: number) -> (),
}

type Proto = {
	name: string,
	balance: number
}

local module = {}

local Account: Impl = {} :: Impl
Account.__index = Account

export type Account = typeof(setmetatable({} :: Proto, {} :: Impl))

function module.new(name: string, balance: number): Account
	local self = setmetatable({} :: Proto, Account)
	self.name = name
	self.balance = balance
	return self
end

function Account:withdraw(debit): ()
	self.balance -= debit
end

function Account:deposit(credit): ()
	self.balance += credit
end

return module

@2jammers What about this? It prevents chaining .new over and over. (Account.new().new().new()). It also hides methods if you haven’t created a class object yet.

Also you can combine proto and impl but I can understand the organizational benefits of having them separated. All to preference.

2 Likes

I’ve actually seen those videos before lol, thanks for linking them again though. When I have an idea and start writing code to prototype it out, I neverrr use OOP to begin with. If I decide use the whole ‘table with both data and metamethod-invoked functions,’ which is what I call Lua OOP, it’s only ever something I do to “neaten it up” afterward. Even then, it’s just so my code doesn’t look different from most of the resources I find on here or in the OSS Discord.

Most of the time I just use modules or do blocks if I need encapsulation. I rarely see people use the return function() end syntax in modules but I do it all the time.

I like @debugMage’s example, since it separates the constructor function from the ‘object’ functions. However, I would extend this idea even further: I wouldn’t return module.new. I wouldn’t even have a module table or a function called new. I would just return that function and then name the module Create Account. Why return anything other than the constructor function if the module is only intended to be interacted with using the constructor’s return value (the ‘object’)?

local createAccount = require(Path:FindFirstChild("Create Account"))
local myAccount = createAccount(LocalPlayer.Name, 999)
myAccount:Withdraw(myAccount:GetTotal())

That’s how my version would look, but that’s assuming I would use the OOP structure to begin with.

1 Like