Emulating Object Oriented Programming in Roblox Development
For the past several weeks, I’ve been diving back into Roblox development, and reacclimating myself has been a blast, to say the least.
I’ve had the most fun relearning Object-Oriented Programming (OOP) in Luau — or rather, the lack of it. Luau has a barebones syntax and no traditional class setup, but we can emulate OOP patterns using techniques that we’ll discuss.
The more interesting challenge is making those designs understandable and easy to debug. Recently, Roblox introduced strict type checking. Strict means the Luau type checker automatically tracks value types and flags type errors as we write code.
--!strict --required at the top of the script!
local function deposit(balance: number, amount: number): number
return balance + amount
end
print(deposit(100, 20))
print(deposit("100", 20)) -- this gets flagged in the editor!This, combined with type references, makes it very easy to catch mistakes in the editor before they become runtime errors.
Which is a nice segue to the topic of today:
How do we build classes that are easy to reuse, understand, and catch mistakes in a language without built-in class syntax?
But before we do all that, we need to understand how we write OOP classes in Luau.
It’s Tables All The Way Up
This is going to be a long one, so we’ll start nice and easy. Here’s Roblox’s canonical OOP example without strict typing or type references.
local Account = {}
Account.__index = Account
function Account.new(name, balance)
local self = {}
self.name = name
self.balance = balance
return setmetatable(self, Account)
end
function Account:Deposit(amount)
self.balance = self.balance + amount
end
local account = Account.new("John Doe", 500)That’s a lot of boilerplate. Let’s break it down.
As mentioned before, Luau doesn’t have classes like traditional programming languages like Python. Tables are the main data object for anything and everything.
Tables are most similar to hash maps in traditional languages. If you’re familiar with Python, Luau’s syntax is shockingly similar!
local Account = {
name = "John Doe",
balance = 100,
}
-- Inserts a new key-value pair using bracket notation
Account["balance"] = 150
-- Alternatively, we can use dot notation
Account.name = "Jane Doe"In Luau, functions are first-class citizens, meaning you can assign them to variables.
local balance = 100
local deposit = function(amount)
balance = balance + amount
endOr you can even….
local balance = 100
local Account = {
deposit = function(amount)
balance = balance + amount
end
}…add a function to a table!
We add functions and values to tables to create rudimentary classes in Luau.
local Account = {
name = "John Doe",
balance = 100,
deposit = function(self, amount)
self.balance = self.balance + amount
end
}The self parameter refers to the original table, so the deposit function here updates the value of balance in the Account table.
If you’ve ever written in Python, it’s basically the same as the keyword self.
class Account:
def __init__(self):
self.name = "John Doe"
self.balance = 0
def deposit(self, amount):
self.balance += amountSo now we have a basic Account class with member variables and methods, but no easy way to create instances of it.
local Account = {
name = "John Doe",
balance = 100,
deposit = function(self, amount)
self.balance = self.balance + amount
end
}
print(Account.name) -- prints "John Doe"
Account.deposit(100)
print(Account.balance) -- prints 200We take this setup a step further and define a dedicated constructor function that creates new instances of our Account class.
local function accountConstructor(newName)
local Account = {
name = newName,
balance = 100,
deposit = function(self, amount)
self.balance = self.balance + amount
end
}
return Account
endNow we can have easily create multiple independent instances of our Account class
local johnAccount = accountConstructor("John")
local janeAccount = accountConstructor("Jane")
johnAccount:deposit(100)
print(johnAccount.balance) -- 100
print(janeAccount.balance) -- 0Each account owns its own balance. Depositing into John’s account doesn’t affect Jane’s.
And this approach works… somewhat. The major problem with this is our deposit method. Every time we call the accountConstructor, we create a new functionally identical instance of the deposit function.
As our programs scales and we create hundreds of Account instances, this becomes a huge waste of resources. It’d be much more efficient to define the function once and have all instances of our class use it. Now, if only there were a way…
Metatables
Metatables are tables that contain Metamethods. Metamethods are special functions that are invoked when a certain action is performed on a table. The full list of metamethods is listed here — they’re all super cool!
The most important one for our case is __index(table, index), which affects how indexing is resolved on a table.
Simply put, if we try to access a value from the table and it isn’t present, we check whatever table is set as the index for that value.
local BaseAccount = {
currency = "USD"
}
local UserAccount = {}
local metatable = {__index = BaseAccount}
print(UserAccount["currency"]) -- prints nil
setmetatable(UserAccount, metatable)
print(UserAccount["currency"])Ordinarily, our attempt to access the currency key in UserAccount would fail, but since we’ve defined a metatable, any index to the UserAccount table first checks the UserAccount table itself, then the BaseAccount table!
Now let’s circle back to our class implementation. Let’s use this same concept to avoid the multiple function issue we referenced before: we’ll create separate table that houses all of our class methods.
local AccountMethods = {
deposit = function(self, amount)
self.balance = self.balance + amount
end
}
function accountConstructor(value: number)
local Account = {
name = newName,
balance = 0
}
setmetatable(Account, {__index = AccountMethods})
return Account
endNow we can use all the methods of our Account and we only have to define them once!
local johnAccount = accountConstructor("John")
local janeAccount = accountConstructor("Jane")
johnAccount:deposit(100)
print(johnAccount.balance) -- 100
print(janeAccount.balance) -- 0
print(johnAccount.deposit == janeAccount.deposit) -- trueThe balances belong to each account, while the deposit is resolved through the shared method table. We’ve separated instance data from shared methods.
Back to Accounting
Now that we’ve completed our training, the Roblox’s example Account class should make a lot more sense.
local Account = {}
Account.__index = Account
function Account.new(name, balance)
local self = {}
self.name = name
self.balance = balance
return setmetatable(self, Account)
end
function Account.deposit(self, amount)
self.balance += amount
end
local account = Account.new("Alexander", 500)Like before, let’s break things down step by step
local Account = {}The first Account table holds all the methods for our Account class.
function Account.new(self, credit)function Account.new(name, balance) looks scary at first, but it’s just syntactic sugar — a simpler way of writing this:
Account["new"] = function(name, balance)
-- Create the new account
end
-- alternatively, we can write..
Account.new = function(name, balance)
--Create the new account
endWith our knowledge in mind, deciphering this constructor (new) is a piece of cake.
local Account = {}
function Account.new(name, balance)
local self = {}
self.name = name
self.balance = balance
return setmetatable(self, Account)
endWe first create a new table called self; it contains all the member variables unique to this account instance, such as the name and balance.
We finally set the metatable of self to be the Account table. To reiterate, the Account table’s main purpose is for handling methods specific to this class.
One small wrinkle readers may have noticed is that we defined our metamethods and the class methods in the same Accounts table; whereas in our previous examples, we kept the metamethods and class methods in separate tables. Both approaches are functionally identical.
Especially eagle-eyed readers might notice that this pattern is effectively identical to the Prototypal Inheritance you’ll find in JavaScript.
The Account table is the prototype that exposes a method new to make new instances of it. self is the data instance.
Get Uche Uche-Ike’s stories in your inbox
Join Medium for free to get updates from this writer.
From here on out, I’ll refer to the table containing all the methods as a Prototype and the individual instance of the class with all the member variables as the Data Instance.
Describing Classes with Types
That was one hell of a background, but hopefully all this context makes things make a bit more sense when we introduce strict typing and type references. Here’s Account example from Roblox’s official docs but with strict typing.
local Account = {}
Account.__index = Account
type AccountData = {
name: string,
balance: number,
}
export type Account = typeof(setmetatable({} :: AccountData, Account))
function Account.new(name, balance): Account
local self = {}
self.name = name
self.balance = balance
return setmetatable(self, Account)
end
function Account.deposit(self: Account, amount: number): ()
self.balance += amount
endIt’s largely the same as the Account class we looked at just a second prior. The most notable thing is the new type declaration we made for Account.
type AccountData = {
name: string,
balance: number,
}
export type Account = typeof(setmetatable({} :: AccountData, Account))Here, the AccountData type refers to the contents of our Data Instance; that’s what self refers to inside the constructor. self’s metatable points to the table Account.
A subtle different you probably didn’t notice is that all the methods in the strict-typing version of the Account class are made using dot-notation.
--!strict
function Account.deposit(self: Account, amount: number): ()
self.balance += amount
endWhereas in the non-strict typing version, we used colon notation. Colon notation is syntactic sugar for dot notation. The main difference is that with Colon notation, the self parameter is added implicitly.
function Account:deposit(credit) -- self is an implicit parameter here
-- can still access self, even though its not declared as a parameter
self.balance += credit
endUsing dot notation, we can add a type reference to self and enforce the strict typing check on whatever member variables or class functions we use in the method.
function Account.deposit(self: Account, amount: number): ()
-- Works fine!
self.balance += amount
-- Shows error in editor
self.account_type = "Savings"
endWhereas without strict typing, we could easily overlook this mistake.
Making the Pattern My Own
Since reacclimating into Roblox development, I’ve made my current style a mixture of my own preferences alongside some tricks I found by other Luau developers.
Here’s the set up for the Account class implementation again in my style.
local AccountPrototype = {}
AccountPrototype.ClassName = "Account"
AccountPrototype.__index = AccountPrototype
type AccountData = {
name: string,
balance: number,
}
type AccountPrototype = typeof({ __index = AccountPrototype })
export type Account = typeof(setmetatable(
{} :: AccountData,
{} :: AccountPrototype
))
-- Class Methods here are largely unchanged
function AccountPrototype.Destroy(self: Account): ()
setmetatable(self :: any, nil)
table.freeze(self)
end
table.freeze(AccountPrototype)Let’s break it down.
The first things that probably catches your eye is that I don’t name the table with the class methods “Account”. Rather, I mimic the naming convention of the Prototype pattern I mentioned a second ago. AccountPrototype for the table that store class methods and AccountData for the table that stores class member variables. I find these names useful because they make the distinction between stored data and shared methods explicit.
local AccountPrototype = {}
AccountPrototype.ClassName = "Account"The next thing that probably catches your eye is fields ClassName. Since Lua has no compile time type-checking, giving each class an explicitClassName field makes it possible validate classes at runtime.
--!strict
local function isAnAccount(object: {any})
return (object.ClassName == "Account")
end
function Bank.RegisterAccount(self: Bank, account: Account)
-- Code crashes immediately if the account argument isn't valid
assert(isAnAccount(account), "\"account\" argument must be an instance of Account class")
-- Registration logic here...
endFinally, there’s the logic for destroying the class.
--!strict
function Account.Destroy(self: Account): ()
setmetatable(self :: any, nil)
table.freeze(self)
endI like to create a destroy method with three essential steps:
Removing the metatable prevents the instance from finding methods through its prototype. Freezing the table prevents further changes to its fields.
local johnAccount = Account.new("John Doe")
johnAccount:Destroy()
johnAccount.deposit(100) -- Error
johnAccount.name = "Jack" -- Error Both these actions mean that once destroyed an Account instance is effectively useless.
Programming to an Interface
So far, our systems have known exactly what kind of account they’re working with.
But often, a system doesn’t need to know the specific class. A reward system, for example, might only care that it has something with a Deposit method.
That’s where interfaces come in. Instead of describing one particular class, an interface describes the methods another system can rely on.
In traditional languages, we may declare a class as an Interface or define Abstract methods.
from abc import ABC, abstractmethod
class DepositAccount(ABC):
@abstractmethod
def deposit(self, amount: float):
pass
class Account(DepositAccount):
def __init__(self):
self.deposit = 0
def deposit(self, amount: float) -> bool:
self.deposit += amountIn Luau, there’s no pecial keyword for interfaces. We define a type that describes the shape another object needs to have.
export type DepositAccountInterface = {
Deposit: (self: DepositAccountInterface, amount: number) -> (),
}local function awardWelcomeBonus(account: DepositAccountInterface)
account:Deposit(100)
endThe payoff is that any class can satisfy this contract, as long as they provide a compatible Deposit method.
When creating classes that implements this interface, we can create an intersection between the type and the class type.
local AccountPrototype = {}
AccountPrototype.ClassName = "Account"
export type Account = typeof(setmetatable(
{} :: AccountData,
{} :: (AccountPrototype & DepositAccountInterface)
))awardWelcomeBonus(AccountPrototype.new("John", 0))We use an intersection type to say that AccountPrototype must provide both its own methods and everything required by DepositAccountInterface.
//.. Account boilerplate
function AccountPrototype.new(name, balance): Account
local self = {}
self.name = name
self.balance = balance
// Error here: Account instance is missing deposit method
return setmetatable(self, Account)
endNow unless we define the deposit method, we’ll see a strict typing error in our constructor method.
Specialising with Inheritance
Let’s define a SavingsAccount class as a subclass of Account.
local SavingsAccountPrototype = {}
SavingsAccountPrototype.Super = Account
SavingsAccountPrototype.ClassName = "SavingsAccount"
setmetatable(SavingsAccountPrototype, { __index = AccountPrototype })
SavingsAccountPrototype.__index = SavingsAccountPrototype
type SavingsAccountData = AccountData & {
interestRate: number,
}
type SavingsAccountPrototype = typeof({ __index = SavingsAccountPrototype })
export type SavingsAccount = typeof(setmetatable(
{} :: SavingsAccountData,
{} :: SavingsAccountPrototype
))The set up is similar to the Account we looked at a second ago with three key differences.
setmetatable(SavingsAccountPrototype, { __index = AccountPrototype })We create an anonymous table with the __index set asAccountPrototype . And set SavingsAccountPrototype’s metatable to be that anonymous table. This sets up a chain between the two prototypes that instances of SavingsAccount follow.
For any field thats not present in the SavingsAccountData, we check SavingsAccountPrototype and then AccountPrototype. This set up effectively simulates inheritance properties like method overriding.
type SavingsAccountData = AccountData & {
interestRate: number,
}We define the type of SavingsAccountData to be the intersection of its member variables and AccountData’s member variables (that’s why separating them is useful!). So our new SavingsAccountData class has the same member variables as AccountData plus the new interestRate field.
--!strict
function SavingsAccountPrototype.applyInterest(self: SavingsAccountPrototype): ()
-- No type check error on "balance"
local interestAccumulated = self.balance * self.interestRate
-- No type check error on "deposit"
self:deposit(interestAccumulated)
end
local savings = SavingsAccountPrototype.new("Jane", 1000, 0.05)
savings:Deposit(200)
savings:ApplyInterest()
print(savings.balance) -- 1260Deposit is not defined on SavingsAccountPrototype, so Luau finds it through the prototype chain. ApplyInterest is defined on the savings prototype and uses that inherited method.
Explicit Parent Access
SavingsAccountPrototype.Super = Account
--...
function SavingsAccountPrototype.new(name: string, initial_balance: number, interestRate: number): SavingsAccount
local self = self.Super.new(name, initial_balance)
self.interestRate = interestRate
setmetatable(self, SavingsAccountPrototype)
return self
endNear the top of the file, we define the Super , the parent class, of SavingsAccountPrototype to be AccountPrototype .
We could alternatively just call using AccountPrototype table, but I find that this way makes it more obvious that the function call is meant to call the parent class’ constructor.
Class Validation With Inheritance
You may recall that for my Account class I suggested adding a special ClassName field.
AccountPrototype.ClassName = "Account"We could add a new case to the isAnAccount function we made earlier..
function isAnAccount(self: Account):
return self.ClassName == "Account" or self.ClassName == "SavingsAccount"But it scales poorly and separates the class inheritance logic from the class definition. I’m not a fan of it.
Alternatively, we can define an IsA() method for all classes
--!strict
function AccountPrototype.IsA(self: AccountPrototype, className: string): boolean
local currentClass = getmetatable(self) or self
while currentClass do
if currentClass.ClassName == className then
return true
end
currentClass = getmetatable(currentClass)
local isAClass: boolean = type(currentClass) == "table" and currentClass.__index
if isAClass then
currentClass = currentClass.__index
else
break
end
end
return false
endFor all our Account related validation checks, we can just verify whether the class is an “Account”.
function Bank.RegisterAccount(self: Bank, account: Account)
-- Works for both SavingsAccounts and regular Accounts
assert(account:IsA("Account"), "\"account\" argument must be an instance of Account class")
-- Registration logic here...
endConclusion
Relearning about Object Oriented Programming in Roblox Studio has easily been the most enjoyable part of me returning to the platform. Coming from more traditional programming languages makes you take Class implementations for granted, and having to think critically about how to emulate those patterns in Luau has given me a newfound appreciation for OOP. I hope this article helps you better understand how classes, tables, work and how we can leverage OOP patterns to write more robust Luau code.










