Overview
Lua has no class keyword, yet object-oriented code is everywhere in Lua — in game engines, in Roblox, in Neovim plugins. It is all built on one mechanism: metatables. This project builds a small banking system with real classes, inheritance and operator overloading, so you can see exactly how that mechanism works rather than relying on a library to hide it.
An Account stores a balance and a transaction history. A SavingsAccount inherits everything from Account, adds interest and a withdrawal limit, and overrides withdraw. Metamethods let you print an account directly, sort accounts by balance with <, and add two accounts together with +. Invalid operations raise errors that a transfer function catches with pcall, so a failed transfer never leaves money half-moved.
- An
Accountclass usingsetmetatableand__index, with anewconstructor. depositandwithdrawmethods that validate input and raise errors witherror().__tostring,__ltand__addmetamethods for printing, sorting and combining accounts.- A
SavingsAccountsubclass that inherits methods and overrideswithdraw. - A
transferfunction that usespcallto roll back safely if anything fails.
Prerequisites
- Tables and functions stored in tables — lessons 5 and 7.
- Closures and the colon (
:) method syntax — lessons 7 and 8. - Metatables,
__indexand object-oriented Lua — lesson 9. error,pcalland error handling — lesson 11.
Project Structure
The program is one file, bank.lua. Account is a table that serves two roles at once: it holds the shared methods, and it is the metatable for every account object, so missing fields on an object are looked up in Account through __index. SavingsAccount repeats the trick one level up — its own metatable points to Account — which is all inheritance is in Lua.
Step 1: The Account Class
Account.__index = Account is the key line: when you call acct:deposit(100) and the object has no deposit field, Lua looks in its metatable's __index, finds Account, and calls Account.deposit(acct, 100). new uses self rather than Account so subclasses can reuse the same constructor.
local Account = {}Account.__index = Account
local next_number = 1000
function Account.new(class, owner, opening_balance) next_number = next_number + 1 local account = setmetatable({ number = next_number, owner = owner, balance = 0, history = {}, }, class) if opening_balance and opening_balance > 0 then account:deposit(opening_balance) end return accountendCalling Account:new("Asha", 5000) passes Account as the first argument, class, so the new object's metatable becomes Account. Later, SavingsAccount:new(...) will pass SavingsAccount instead — with no change to this function.
Step 2: Deposits, Withdrawals & Errors
error(message, 2) raises an error and blames the caller (level 2) rather than this function, so the message points at the line that passed a bad amount. A small record helper keeps the history consistent for every kind of transaction.
local function check_amount(amount) if type(amount) ~= "number" or amount <= 0 then error("amount must be a positive number", 3) endend
function Account:record(kind, amount) self.history[#self.history + 1] = ("%-10s %10.2f -> balance %10.2f"):format(kind, amount, self.balance)end
function Account:deposit(amount) check_amount(amount) self.balance = self.balance + amount self:record("deposit", amount)end
function Account:withdraw(amount) check_amount(amount) if amount > self.balance then error(("insufficient funds in #%d (balance %.2f, requested %.2f)"):format(self.number, self.balance, amount), 2) end self.balance = self.balance - amount self:record("withdraw", amount)endStep 3: Metamethods: __tostring, __lt & __add
Metamethods are special keys in the metatable that Lua calls for built-in operations. __tostring is used by print and tostring; __lt makes a < b work, which is all table.sort needs; __add makes a + b return the combined balance.
function Account.__tostring(a) return ("#%d %-6s %-8s %10.2f"):format(a.number, a.owner, a.kind or "current", a.balance)end
function Account.__lt(a, b) return a.balance < b.balanceend
function Account.__add(a, b) return a.balance + b.balanceendStep 4: SavingsAccount Inheritance
setmetatable(SavingsAccount, { __index = Account }) makes SavingsAccount fall back to Account for anything it does not define, so deposit and record are inherited automatically. The overriding withdraw enforces a limit and then calls Account.withdraw(self, amount) — the Lua equivalent of calling super. Metamethods are not inherited through __index, so they are copied across explicitly.
local SavingsAccount = setmetatable({}, { __index = Account })SavingsAccount.__index = SavingsAccountSavingsAccount.__tostring = Account.__tostringSavingsAccount.__lt = Account.__ltSavingsAccount.__add = Account.__add
SavingsAccount.kind = "savings"SavingsAccount.rate = 0.04 -- 4% a yearSavingsAccount.max_withdrawal = 10000
function SavingsAccount:withdraw(amount) if amount > self.max_withdrawal then error(("savings withdrawals are limited to %.2f"):format(self.max_withdrawal), 2) end Account.withdraw(self, amount) -- call the parent versionend
function SavingsAccount:add_monthly_interest() local interest = math.floor(self.balance * self.rate / 12 * 100 + 0.5) / 100 self:deposit(interest) return interestendStep 5: Safe Transfers with pcall
pcall(f, ...) calls f in protected mode: instead of crashing, it returns false plus the error message. The transfer withdraws first and only deposits if the withdrawal succeeded; if the deposit fails, the money is put back. string.gsub strips the "file:line:" prefix that Lua adds to error messages.
local function transfer(from, to, amount) local ok, err = pcall(from.withdraw, from, amount) if not ok then return false, (tostring(err):gsub("^.-:%d+: ", "")) end ok, err = pcall(to.deposit, to, amount) if not ok then from.balance = from.balance + amount -- roll back the withdrawal return false, (tostring(err):gsub("^.-:%d+: ", "")) end return trueendComplete Code
-- bank.lua — run with: lua bank.lualocal Account = {}Account.__index = Account
local next_number = 1000
function Account.new(class, owner, opening_balance) next_number = next_number + 1 local account = setmetatable({ number = next_number, owner = owner, balance = 0, history = {}, }, class) if opening_balance and opening_balance > 0 then account:deposit(opening_balance) end return accountend
local function check_amount(amount) if type(amount) ~= "number" or amount <= 0 then error("amount must be a positive number", 3) endend
function Account:record(kind, amount) self.history[#self.history + 1] = ("%-10s %10.2f -> balance %10.2f"):format(kind, amount, self.balance)end
function Account:deposit(amount) check_amount(amount) self.balance = self.balance + amount self:record("deposit", amount)end
function Account:withdraw(amount) check_amount(amount) if amount > self.balance then error(("insufficient funds in #%d (balance %.2f, requested %.2f)"):format(self.number, self.balance, amount), 2) end self.balance = self.balance - amount self:record("withdraw", amount)end
function Account.__tostring(a) return ("#%d %-6s %-8s %10.2f"):format(a.number, a.owner, a.kind or "current", a.balance)end
function Account.__lt(a, b) return a.balance < b.balanceend
function Account.__add(a, b) return a.balance + b.balanceend
local SavingsAccount = setmetatable({}, { __index = Account })SavingsAccount.__index = SavingsAccountSavingsAccount.__tostring = Account.__tostringSavingsAccount.__lt = Account.__ltSavingsAccount.__add = Account.__add
SavingsAccount.kind = "savings"SavingsAccount.rate = 0.04 -- 4% a yearSavingsAccount.max_withdrawal = 10000
function SavingsAccount:withdraw(amount) if amount > self.max_withdrawal then error(("savings withdrawals are limited to %.2f"):format(self.max_withdrawal), 2) end Account.withdraw(self, amount) -- call the parent versionend
function SavingsAccount:add_monthly_interest() local interest = math.floor(self.balance * self.rate / 12 * 100 + 0.5) / 100 self:deposit(interest) return interestend
local function transfer(from, to, amount) local ok, err = pcall(from.withdraw, from, amount) if not ok then return false, (tostring(err):gsub("^.-:%d+: ", "")) end ok, err = pcall(to.deposit, to, amount) if not ok then from.balance = from.balance + amount -- roll back the withdrawal return false, (tostring(err):gsub("^.-:%d+: ", "")) end return trueend
-- ---- Demo ----local asha = Account:new("Asha", 5000)local ravi = Account:new("Ravi", 1200)local nest_egg = SavingsAccount:new("Asha", 60000)
local function try_transfer(from, to, amount) local ok, err = transfer(from, to, amount) print(("Transfer %8.2f #%d -> #%d : %s"):format(amount, from.number, to.number, ok and "OK" or ("FAILED - " .. err)))end
try_transfer(asha, ravi, 1500)try_transfer(ravi, asha, 9999)try_transfer(nest_egg, asha, 15000)try_transfer(nest_egg, asha, 8000)try_transfer(asha, ravi, -20)print(("Interest added to #%d: %.2f"):format(nest_egg.number, nest_egg:add_monthly_interest()))print()
local all = { asha, ravi, nest_egg }table.sort(all) -- uses __ltprint("Accounts by balance:")for _, account in ipairs(all) do print(" " .. tostring(account)) -- uses __tostringendprint(("Asha's combined balance: %.2f"):format(asha + nest_egg)) -- uses __addprint()
print(("History of #%d:"):format(nest_egg.number))for _, line in ipairs(nest_egg.history) do print(" " .. line)endprint()print("Inherited deposit? " .. tostring(nest_egg.deposit == Account.deposit))print("Overridden withdraw? " .. tostring(nest_egg.withdraw ~= Account.withdraw))Sample Run
Click Run to see what this code prints.
Extend This Project
- Add an
__eqmetamethod so two accounts with the same number compare as equal. - Add a
CurrentAccountsubclass with an overdraft limit that allows the balance to go below zero. - Make
balanceread-only from outside by storing it in a closure and exposing aget_balancemethod. - Return structured error tables (
{ code = "INSUFFICIENT_FUNDS", ... }) instead of strings, and branch onerr.code. - Move the classes into a
bankmodule andrequireit from a separate demo script.
Summary
You implemented classes, inheritance and operator overloading using nothing but tables and metatables: __index for method lookup and inheritance, __tostring, __lt and __add for built-in operators, and pcall with error for safe, recoverable failures. Every Lua OOP library — and every game engine that scripts objects in Lua — is built from exactly these pieces.