Metatables

Metatables enable operator overloading, custom behavior, and object-oriented programming in Lua.

Metatable Basics

What is a Metatable?

A metatable is a regular table that defines special behavior for another table through metamethods.

local t = {}
local mt = {}

-- Set metatable
setmetatable(t, mt)

-- Get metatable
local mt2 = getmetatable(t)

-- Inline
local t = setmetatable({}, {})

Metamethods

Metamethods are special keys in metatables that Lua looks for when performing operations:

Metamethod Triggered By

__index

Accessing missing key

__newindex

Setting new key

__call

Calling table as function

__tostring

tostring() conversion

add, sub, mul, div

Arithmetic operators

eq, lt, __le

Comparison operators

__len

# operator

__concat

.. operator

__index (Key Lookup)

-- __index as table (fallback/defaults)
local defaults = {
    port = 443,
    timeout = 30
}

local config = setmetatable({
    hostname = "ise-01"
}, {
    __index = defaults
})

print(config.hostname)  -- "ise-01" (own key)
print(config.port)      -- 443 (from defaults)
print(config.missing)   -- nil (not in either)

-- __index as function (computed values)
local cache = setmetatable({}, {
    __index = function(t, key)
        print("Computing " .. key)
        local value = expensive_compute(key)
        t[key] = value  -- Cache it
        return value
    end
})

Chained __index

-- Multiple inheritance / prototype chain
local animal = {
    speak = function() print("...") end
}

local dog = setmetatable({
    bark = function() print("Woof!") end
}, { __index = animal })

local mydog = setmetatable({
    name = "Rex"
}, { __index = dog })

mydog.bark()   -- "Woof!" (from dog)
mydog.speak()  -- "..." (from animal)

__newindex (Key Assignment)

-- Track assignments
local tracked = setmetatable({}, {
    __newindex = function(t, key, value)
        print(string.format("Setting %s = %s", key, tostring(value)))
        rawset(t, key, value)  -- Actually set it
    end
})

tracked.foo = "bar"  -- Prints: Setting foo = bar

-- Read-only table
local readonly = setmetatable({
    constant = 42
}, {
    __newindex = function()
        error("Table is read-only")
    end
})

-- readonly.x = 1  -- Error!

-- Validation on assignment
local validated = setmetatable({}, {
    __newindex = function(t, key, value)
        if key == "port" and (type(value) ~= "number" or value < 1 or value > 65535) then
            error("Invalid port: " .. tostring(value))
        end
        rawset(t, key, value)
    end
})

__call (Callable Tables)

-- Make table callable like a function
local Greeter = setmetatable({
    prefix = "Hello"
}, {
    __call = function(self, name)
        return self.prefix .. ", " .. name .. "!"
    end
})

print(Greeter("Evan"))  -- "Hello, Evan!"
Greeter.prefix = "Hi"
print(Greeter("Evan"))  -- "Hi, Evan!"

-- Factory pattern
local Endpoint = setmetatable({}, {
    __call = function(_, mac, ip, vlan)
        return {
            mac = mac,
            ip = ip,
            vlan = vlan
        }
    end
})

local ep = Endpoint("00:11:22:33:44:55", "10.50.10.100", 10)

__tostring

local endpoint = setmetatable({
    mac = "00:11:22:33:44:55",
    ip = "10.50.10.100",
    vlan = 10
}, {
    __tostring = function(self)
        return string.format("Endpoint<%s @ %s VLAN %d>",
            self.mac, self.ip, self.vlan)
    end
})

print(endpoint)  -- "Endpoint<00:11:22:33:44:55 @ 10.50.10.100 VLAN 10>"

Arithmetic Metamethods

local Vector = {}
Vector.__index = Vector

function Vector.new(x, y)
    return setmetatable({x = x, y = y}, Vector)
end

function Vector:__add(other)
    return Vector.new(self.x + other.x, self.y + other.y)
end

function Vector:__sub(other)
    return Vector.new(self.x - other.x, self.y - other.y)
end

function Vector:__mul(scalar)
    return Vector.new(self.x * scalar, self.y * scalar)
end

function Vector:__eq(other)
    return self.x == other.x and self.y == other.y
end

function Vector:__tostring()
    return string.format("Vector(%d, %d)", self.x, self.y)
end

local v1 = Vector.new(1, 2)
local v2 = Vector.new(3, 4)

print(v1 + v2)     -- Vector(4, 6)
print(v2 - v1)     -- Vector(2, 2)
print(v1 * 3)      -- Vector(3, 6)
print(v1 == v1)    -- true

OOP with Metatables

Basic Class

local Endpoint = {}
Endpoint.__index = Endpoint

function Endpoint.new(mac, ip, vlan)
    local self = setmetatable({}, Endpoint)
    self.mac = mac
    self.ip = ip
    self.vlan = vlan
    self.status = "unknown"
    return self
end

function Endpoint:is_active()
    return self.status == "active"
end

function Endpoint:activate()
    self.status = "active"
end

function Endpoint:__tostring()
    return string.format("Endpoint(%s)", self.mac)
end

-- Usage
local ep = Endpoint.new("00:11:22:33:44:55", "10.50.10.100", 10)
ep:activate()
print(ep:is_active())  -- true
print(ep)              -- Endpoint(00:11:22:33:44:55)

Inheritance

-- Base class
local Device = {}
Device.__index = Device

function Device.new(hostname, ip)
    local self = setmetatable({}, Device)
    self.hostname = hostname
    self.ip = ip
    return self
end

function Device:connect()
    print("Connecting to " .. self.hostname)
    return true
end

function Device:get_info()
    return {hostname = self.hostname, ip = self.ip}
end

-- Derived class
local ISENode = setmetatable({}, {__index = Device})
ISENode.__index = ISENode

function ISENode.new(hostname, ip, roles)
    local self = Device.new(hostname, ip)
    setmetatable(self, ISENode)
    self.roles = roles or {}
    return self
end

function ISENode:is_pan()
    for _, role in ipairs(self.roles) do
        if role == "PAN" then return true end
    end
    return false
end

function ISENode:get_info()
    local info = Device.get_info(self)  -- Call parent
    info.roles = self.roles
    return info
end

-- Usage
local ise = ISENode.new("ise-01", "10.50.1.20", {"PAN", "MNT"})
ise:connect()           -- "Connecting to ise-01" (inherited)
print(ise:is_pan())     -- true (own method)

Class Helper Function

-- Reusable class creation
local function class(parent)
    local cls = {}
    cls.__index = cls

    if parent then
        setmetatable(cls, {__index = parent})
    end

    function cls.new(...)
        local self = setmetatable({}, cls)
        if self.init then
            self:init(...)
        end
        return self
    end

    return cls
end

-- Usage
local Device = class()

function Device:init(hostname, ip)
    self.hostname = hostname
    self.ip = ip
end

function Device:connect()
    print("Connecting to " .. self.hostname)
end

local ISENode = class(Device)

function ISENode:init(hostname, ip, roles)
    Device.init(self, hostname, ip)
    self.roles = roles
end

function ISENode:is_pan()
    return vim.tbl_contains(self.roles, "PAN")
end

local ise = ISENode.new("ise-01", "10.50.1.20", {"PAN"})

Raw Access

-- Bypass metamethods
local t = setmetatable({}, {
    __index = function() return "default" end,
    __newindex = function() error("read-only") end
})

print(t.foo)     -- "default" (via __index)
print(rawget(t, "foo"))  -- nil (bypass __index)

-- t.foo = "bar"  -- Error via __newindex
rawset(t, "foo", "bar")  -- Works (bypass __newindex)

Proxy Tables

-- Create a proxy that logs all access
local function make_proxy(target)
    return setmetatable({}, {
        __index = function(_, key)
            print("GET: " .. key)
            return target[key]
        end,
        __newindex = function(_, key, value)
            print("SET: " .. key .. " = " .. tostring(value))
            target[key] = value
        end,
        __pairs = function()
            return pairs(target)
        end
    })
end

local data = {a = 1, b = 2}
local proxy = make_proxy(data)

print(proxy.a)  -- GET: a, then prints 1
proxy.c = 3     -- SET: c = 3

Neovim Usage

-- Plugin with class-like structure
local M = {}
M.__index = M

function M.new(opts)
    local self = setmetatable({}, M)
    self.opts = vim.tbl_deep_extend("force", {
        enabled = true,
    }, opts or {})
    return self
end

function M:setup()
    if not self.opts.enabled then return end
    -- Setup logic
end

return M

-- Usage in config:
-- require("myplugin").new({ enabled = true }):setup()

Next Module

Modules - require, module patterns, package path.