Lua with Neovim: Metatables — __tostring, __add, __eq, __index & Custom Table Behavior | Episode 29
Video: Lua with Neovim: Metatables — __tostring, __add, __eq, __index & Custom Table Behavior | Episode 29 by Taught by Celeste AI - AI Coding Coach
Lua Metatables: __tostring, __add, __eq, __index
A metatable attached to a table changes how operators and lookups work on it.
__tostringfor custom string conversion.__addfor+.__eqfor==.__indexfor fallback lookups. The mechanism behind operator overloading and OOP.
Metatables are Lua's most powerful feature. They let you customise how the language operates on your tables — operator overloading, inheritance, default values, all built on the same simple mechanism.
Custom __tostring
local vec1 = { x = 3, y = 4 }
setmetatable(vec1, {
__tostring = function(v)
return "(" .. v.x .. ", " .. v.y .. ")"
end,
})
print("vec1: " .. tostring(vec1)) -- "vec1: (3, 4)"
setmetatable(t, mt) attaches mt as t's metatable. When you call tostring(vec1), Lua sees the __tostring metamethod and calls it, passing vec1.
Without the metatable, tostring(vec1) would return "table: 0x...". With it, the user-defined function runs.
This is the simplest metamethod and the most common — give your custom types a sensible string form.
A vector with __add
local Vector = {}
Vector.__index = Vector
function Vector.new(x, y)
local v = { x = x, y = y }
setmetatable(v, Vector)
return v
end
Vector.__tostring = function(v)
return "(" .. v.x .. ", " .. v.y .. ")"
end
Vector.__add = function(a, b)
return Vector.new(a.x + b.x, a.y + b.y)
end
Vector.__eq = function(a, b)
return a.x == b.x and a.y == b.y
end
local a = Vector.new(1, 2)
local b = Vector.new(3, 4)
local c = a + b
print("a + b = " .. tostring(c)) -- "(4, 6)"
print("a == b: " .. tostring(a == b)) -- "false"
local d = Vector.new(1, 2)
print("a == d: " .. tostring(a == d)) -- "true"
Three metamethods on Vector:
__tostring— whattostring(v)returns.__add— whata + bdoes (when at least one side is a Vector).__eq— what==does. Both sides must be Vectors with the same metatable.
Vector.new creates an instance and sets Vector itself as its metatable. So every Vector instance picks up all the metamethods.
The full list of metamethods
Arithmetic:
- __add (+), __sub (-), __mul (*), __div (/)
- __mod (%), __pow (^), __unm (unary -)
- __idiv (//)
Comparison:
- __eq (==), __lt (<), __le (<=)
Other:
- __concat (..)
- __len (#)
- __index — read fallback
- __newindex — write fallback
- __call — make table callable like a function: t(...)
- __tostring
Bitwise (Lua 5.3+):
- __band, __bor, __bxor, __bnot, __shl, __shr
Most code uses just __index, __newindex, __tostring, and a handful of arithmetic/comparison metamethods.
__index as fallback
local defaults = {
color = "blue",
size = 10,
visible = true,
}
local button = { label = "Click me" }
setmetatable(button, { __index = defaults })
print("label: " .. button.label) -- "Click me" (own field)
print("color: " .. button.color) -- "blue" (from defaults!)
print("size: " .. button.size) -- 10 (from defaults!)
When you read button.color, Lua:
- Looks for
colorinbuttonitself. Not found. - Falls back to
__indexfrom the metatable. - If
__indexis a table, looks upcolorthere.
So missing keys silently inherit from defaults. This is the foundation of OOP — methods live in the "class" table, instances inherit by __index.
__index doesn't trigger on writes
button.color = "red"
print("color after set: " .. button.color) -- "red"
print("defaults.color: " .. defaults.color) -- "blue" (unchanged)
Reads fall through to __index; writes go directly to the instance. So setting button.color = "red" adds color to button, leaving defaults untouched.
That's the expected "class-and-instance" behaviour: instance methods can override, and per-instance state lives on the instance.
__index can also be a function
setmetatable(t, {
__index = function(table, key)
return "default value for " .. key
end,
})
print(t.anything) -- "default value for anything"
A function __index runs when a missing key is accessed. Useful for lazy values, computed properties, or proxy objects.
Skipping the metatable: rawget and rawset
local t = setmetatable({}, { __index = function() return 0 end })
print(t.foo) -- 0 (via __index)
print(rawget(t, "foo")) -- nil (skips __index)
rawget(t, k) reads k from t without triggering __index. Useful for "is this key actually there" checks.
Same for rawset(t, k, v), rawequal, rawlen.
getmetatable and __metatable
local mt = getmetatable(t)
print(mt)
getmetatable(t) returns the metatable, or nil if there isn't one. If you set __metatable to a value, getmetatable returns that value (used for hiding the real metatable from outside code).
Method-call sugar with :
function Vector:length()
return math.sqrt(self.x^2 + self.y^2)
end
local v = Vector.new(3, 4)
print(v:length()) -- 5.0
function Vector:length() is sugar for function Vector.length(self) — self is implicit. v:length() is sugar for Vector.length(v).
This colon syntax + __index is what makes Lua look like an OOP language. We dive deeper next episode.
Common stumbles
Forgetting setmetatable. A table with no metatable doesn't trigger any metamethods, no matter what's in the candidate mt table.
Confusing the metatable with the table. setmetatable(t, mt) — t is your data, mt holds the metamethods. Don't put your data fields in mt.
__index triggered for every nested access. A deeply-nested fallback chain is slow. Most OOP designs have at most one or two levels.
Modifying the metatable while in use. Adding/removing metamethods at runtime works but is hard to reason about. Set them up once.
Equality requires the same metatable on both sides. Two tables with __eq defined on different metatables compare with default reference equality. Use the same metatable (i.e., the same class).
What's next
Episode 30: OOP with metatables. Putting it all together: classes, instances, inheritance via chained __index, the :new() constructor pattern, and the colon-syntax convention.
Recap
setmetatable(t, mt) attaches a metatable. Metamethods (__name) customise operators and lookups: __tostring, __add/__sub/etc., __eq/__lt, __index for read-fallback, __newindex for write-fallback. __index as a table gives inheritance; as a function gives computed/lazy values. Writes don't trigger __index — they go straight to the table. rawget/rawset skip metamethods.
Next episode: OOP with metatables.