LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
LuaIntermediate~1.5 hours

Bank Account System with Metatables

Build Account and SavingsAccount classes with metatables, inheritance, operator metamethods and safe errors with pcall.

MetatablesObject-Oriented LuaInheritanceError Handling

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.

What You'll Build
  • An Account class using setmetatable and __index, with a new constructor.
  • deposit and withdraw methods that validate input and raise errors with error().
  • __tostring, __lt and __add metamethods for printing, sorting and combining accounts.
  • A SavingsAccount subclass that inherits methods and overrides withdraw.
  • A transfer function that uses pcall to 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, __index and object-oriented Lua — lesson 9.
  • error, pcall and 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 account
end

Calling 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)
end
end
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

Step 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.balance
end
function Account.__add(a, b)
return a.balance + b.balance
end

Step 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 = SavingsAccount
SavingsAccount.__tostring = Account.__tostring
SavingsAccount.__lt = Account.__lt
SavingsAccount.__add = Account.__add
SavingsAccount.kind = "savings"
SavingsAccount.rate = 0.04 -- 4% a year
SavingsAccount.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 version
end
function SavingsAccount:add_monthly_interest()
local interest = math.floor(self.balance * self.rate / 12 * 100 + 0.5) / 100
self:deposit(interest)
return interest
end

Step 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 true
end

Complete Code

bank.lua
-- bank.lua — run with: lua bank.lua
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 account
end
local function check_amount(amount)
if type(amount) ~= "number" or amount <= 0 then
error("amount must be a positive number", 3)
end
end
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.balance
end
function Account.__add(a, b)
return a.balance + b.balance
end
local SavingsAccount = setmetatable({}, { __index = Account })
SavingsAccount.__index = SavingsAccount
SavingsAccount.__tostring = Account.__tostring
SavingsAccount.__lt = Account.__lt
SavingsAccount.__add = Account.__add
SavingsAccount.kind = "savings"
SavingsAccount.rate = 0.04 -- 4% a year
SavingsAccount.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 version
end
function SavingsAccount:add_monthly_interest()
local interest = math.floor(self.balance * self.rate / 12 * 100 + 0.5) / 100
self:deposit(interest)
return interest
end
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 true
end
-- ---- 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 __lt
print("Accounts by balance:")
for _, account in ipairs(all) do
print(" " .. tostring(account)) -- uses __tostring
end
print(("Asha's combined balance: %.2f"):format(asha + nest_egg)) -- uses __add
print()
print(("History of #%d:"):format(nest_egg.number))
for _, line in ipairs(nest_egg.history) do
print(" " .. line)
end
print()
print("Inherited deposit? " .. tostring(nest_egg.deposit == Account.deposit))
print("Overridden withdraw? " .. tostring(nest_egg.withdraw ~= Account.withdraw))

Sample Run

lua bank.lua

Click Run to see what this code prints.

Extend This Project

  • Add an __eq metamethod so two accounts with the same number compare as equal.
  • Add a CurrentAccount subclass with an overdraft limit that allows the balance to go below zero.
  • Make balance read-only from outside by storing it in a closure and exposing a get_balance method.
  • Return structured error tables ({ code = "INSUFFICIENT_FUNDS", ... }) instead of strings, and branch on err.code.
  • Move the classes into a bank module and require it 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.