Part of Learn Lua with NeoVim

Lua with Neowin: OOP with Metatables — __index, :new(), Colon Syntax & Inheritance | Episode 30

Sandy LaneSandy Lane

Video: Lua with Neowin: OOP with Metatables — __index, :new(), Colon Syntax & Inheritance | Episode 30 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Lua OOP with Metatables: Classes, :new(), and Inheritance

Class = table. Instance = table with the class as __index. : for method-call sugar. setmetatable(Subclass, { __index = Superclass }) for inheritance. Build an Animal with a Dog subclass.

Lua doesn't have a class keyword. OOP is built from tables, metatables, and a few conventions. Today we build a small Animal / Dog hierarchy with proper instances, methods, and inheritance.

The Animal class

-- animal.lua
local Animal = {}
Animal.__index = Animal

function Animal:new(name, sound)
  local o = {
    name = name,
    sound = sound,
  }
  setmetatable(o, self)
  return o
end

function Animal:speak()
  print(self.name .. " says " .. self.sound .. "!")
end

function Animal:describe()
  print("I am " .. self.name)
end

return Animal

Three pieces:

  1. local Animal = {} — the class table.
  2. Animal.__index = Animal — when an instance accesses a missing key, fall back to Animal. That's how methods work.
  3. Animal:new(...) — the constructor.

How :new(...) works

function Animal:new(name, sound)
  local o = { name = name, sound = sound }
  setmetatable(o, self)
  return o
end

The : makes self an implicit first parameter — Animal:new(...) is sugar for Animal.new(self, ...).

Inside:

  1. Create o, a fresh table for this instance.
  2. setmetatable(o, self) — set the class as the instance's metatable. Since Animal.__index = Animal, missing keys on o fall back to Animal.
  3. Return o.

So Animal:new("Whiskers", "meow") returns a table with name, sound and inherited methods.

Using the class

local cat = Animal:new("Whiskers", "meow")
local bird = Animal:new("Tweety", "tweet")

cat:speak()         -- "Whiskers says meow!"
bird:speak()        -- "Tweety says tweet!"
cat:describe()      -- "I am Whiskers"

cat:speak() is sugar for Animal.speak(cat). Inside speak, self is cat, so self.name is "Whiskers".

Inheritance: Dog inherits from Animal

local Animal = require("animal")

local Dog = {}
Dog.__index = Dog
setmetatable(Dog, { __index = Animal })   -- Dog inherits from Animal

function Dog:new(name)
  local o = Animal.new(self, name, "woof")
  return o
end

function Dog:fetch(item)
  print(self.name .. " fetches the " .. item .. "!")
end

The key line:

setmetatable(Dog, { __index = Animal })

This makes Dog itself fall back to Animal for missing methods. So if you look up Dog.describe and it's not on Dog, Lua finds it on Animal.

The Dog:new constructor calls Animal.new(self, name, "woof") — invoking the parent's constructor with self being Dog (or the calling subclass). The result is an instance whose metatable is Dog, so it picks up Dog's methods and Animal's methods via the chained __index.

Using Dog

local rex = Dog:new("Rex")
local buddy = Dog:new("Buddy")

rex:speak()           -- "Rex says woof!"  (inherited from Animal)
rex:fetch("ball")     -- "Rex fetches the ball!"  (Dog method)
rex:describe()        -- "I am Rex"  (inherited)

buddy:speak()
buddy:fetch("stick")

Two-level lookup chain:

  • rex:fetch(...) — found on Dog. Called.
  • rex:speak(...) — not on rex or Dog. Falls back to Animal. Found, called.
  • rex.name — found on rex (instance data).

Why two __indexes?

There are two levels of metatable here:

  1. Dog.__index = Dog — instances of Dog look up methods on Dog.
  2. setmetatable(Dog, { __index = Animal }) — Dog itself looks up missing names on Animal.

Without (1), rex:speak() wouldn't find anything. Without (2), rex:speak() would only see Dog's methods.

The chain: instance → class → superclass → ... → root.

Method override

function Dog:describe()
  print("I am " .. self.name .. ", a good dog.")
end

local rex = Dog:new("Rex")
rex:describe()   -- "I am Rex, a good dog."  (Dog's version, not Animal's)

Define a method on Dog with the same name as one on Animal. The lookup finds Dog.describe first and stops — no fallback to Animal.

To call the parent version explicitly:

function Dog:describe()
  Animal.describe(self)         -- call parent's describe
  print("...and I'm a dog!")
end

A note on : vs .

function Animal.foo(self, x) ... end   -- explicit self
function Animal:foo(x) ... end          -- sugar (same thing)

obj.foo(obj, 5)    -- explicit; can be wrong if you typo
obj:foo(5)         -- sugar (passes obj as self)

The conventions:

  • Use : when defining methods that operate on the instance.
  • Use . when defining "class methods" or static helpers (no self).
  • Use : when calling methods on instances.
  • Use . when accessing class-level fields.

Keep them consistent.

Multiple "instances" of behaviour

local cat = Animal:new("Whiskers", "meow")
local dog = Animal:new("Rex", "woof")
local lion = Animal:new("Simba", "roar")

cat:speak()   -- "Whiskers says meow!"
dog:speak()   -- "Rex says woof!"
lion:speak()  -- "Simba says roar!"

One class, many instances, each with its own state. Standard OOP.

Constructors with defaults

function Animal:new(opts)
  local o = {
    name = opts.name or "Unknown",
    sound = opts.sound or "...",
    age = opts.age or 0,
  }
  setmetatable(o, self)
  return o
end

local cat = Animal:new({ name = "Whiskers", sound = "meow" })

For classes with many fields, accept an options table instead of positional args. Easier to read at the call site.

Common patterns: encapsulation via closures (alt)

OOP via metatables makes class methods public. For encapsulation (private state), the closure pattern from episode 16 is often cleaner:

function make_counter()
  local count = 0
  return {
    next = function() count = count + 1; return count end,
    get = function() return count end,
  }
end

count is genuinely private; only the methods can touch it. Metatable-based OOP is more conventional but can't hide instance fields without extra ceremony.

Common stumbles

Forgetting Animal.__index = Animal. Without it, instances can't find their methods. Most "I made a class but methods don't work" bugs come from this.

Calling Animal.new(...) instead of Animal:new(...). No self passed; name shifts to where self was expected. Use the colon.

Mismatched : and .. obj.method() doesn't pass self; obj:method() does. Pick consistently.

Subclass forgetting to call super constructor. function Dog:new(name) return setmetatable({name=name}, Dog) end — works for simple cases, but parent state isn't initialised. Always call Animal.new(self, ...) explicitly.

Adding to Animal after inheritance is set up. Works fine, but the new method instantly appears on all subclasses. Surprising in big codebases.

What's next

Episode 31: the Neovim Lua API. vim.api, vim.opt, vim.keymap.set, autocommands. Customising Neovim with Lua.

Recap

Lua OOP via metatables: class = table; instance metatable points back to class; __index = self makes method-lookup work. Class:new(...) constructor sets metatable and returns instance. Inheritance: setmetatable(Subclass, { __index = Superclass }) and call Superclass.new(self, ...) from the subclass constructor. : syntax passes self automatically. Override by defining a method on the subclass; call parent explicitly via Parent.method(self, ...).

Next episode: Neovim Lua API.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.