---
url: /luau-basics/metatables-and-oop.md
description: >-
  Customize tables with metamethods, build class-like objects, and prefer
  composition.
---

# Metatables and OOP

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You attach and read metatables safely.
* You build \_\_index chains for fallback lookup.
* You use \_\_newindex, \_\_call, \_\_tostring, \_\_add, \_\_len, and \_\_eq correctly.
* You call methods with colon syntax.
* You build class-like tables with .new.
* You prefer composition and prototype design where it fits.

## Prerequisites

* [Tables Part 1](../programming-fundamentals/tables-part-1.md)
* [Functions and Scope](../programming-fundamentals/functions-and-scope.md)
* [Tables in Depth](./tables-in-depth.md)
* [Advanced Functions](./functions-advanced.md)
* [Modules and Require](./modules-and-require.md)

## Metatables add hidden behavior

A metatable is an ordinary table that you attach to another table. It holds metamethods. Luau calls those metamethods when code indexes, writes, calls, prints, adds, measures, or compares the host table. Attach with `setmetatable` and read with `getmetatable`. The full list lives in [metatables](https://create.roblox.com/docs/en-us/luau/metatables).

Run this pure snippet in the Command Bar.

```luau
--!strict
local pet = {name = "Pip"}
local mt = {}
setmetatable(pet, mt)
print(getmetatable(pet) == mt)
```

```txt
true
```

Build a host table and an empty metatable, then attach one to the other. The identity check prints true. `setmetatable` also returns the host table, so you can attach and return in one line inside constructors.

A locked `__metatable` field hides the real metatable. `getmetatable` then returns that field instead. `setmetatable` onto a protected table errors. The lock suits shipped types that must not change shape at runtime. Skip it in game code where tests need access.

## \_\_index supplies fallback lookups

Luau fires `__index` when a key is missing from the host table. The handler can be a table or a function. A table handler replays the lookup in that table. A function handler computes the answer. Chains form when the fallback table has its own metatable.

Run this pure snippet in the Command Bar.

```luau
--!strict
local defaults = {hp = 100, speed = 16}
local hero = setmetatable({name = "Mara"}, {__index = defaults})
print(hero.name)
print(hero.hp)
```

```txt
Mara
100
```

`name` lives on the hero, so no fallback fires. `hp` is missing, so Luau reads it from `defaults`. Table fallbacks suit defaults, roles, and species stats. Keep defaults frozen so instances cannot edit shared tuning.

A function fallback computes per-key answers. It receives the host table and the missing key. It suits lazy values and helpful errors.

Run this pure snippet in the Command Bar.

```luau
--!strict
local t = setmetatable({}, {
	__index = function(host, key)
		return `missing {key}`
	end,
})
print(t.sword)
```

```txt
missing sword
```

The handler builds a message from the key. Every missing read returns a sentence naming the key. This shape suits debug proxies, not gameplay data. Silent string fallbacks hide typos in real systems.

Chain depth stays shallow. One fallback level covers most designs. Two levels suit species plus global defaults. Deeper chains slow lookups and confuse readers. Flatten a chain that needs a comment to explain it.

## \_\_newindex guards writes

Luau fires `__newindex` when code writes a missing key. Like `__index`, it accepts a table or a function. A table handler stores the write in that table. A function handler decides what to do. Existing keys bypass the handler and write directly.

Run this pure snippet in the Command Bar.

```luau
--!strict
local log = {}
local proxy = setmetatable({}, {
	__newindex = function(host, key, value)
		table.insert(log, key)
		rawset(host, key, value)
	end,
})
proxy.hp = 40
print(log[1])
print(proxy.hp)
```

```txt
hp
40
```

The handler records the key, then stores the value with `rawset` to avoid looping back into itself. Later reads find `hp` directly. This shape suits change tracking, validation, and read-only proxies that error instead of storing.

Keep guards simple. Validate type and range, then store or error. Complex guards belong in named methods where tests can call them. Metamethods should stay thin because they fire on everyday syntax.

## \_\_call makes tables callable

A table with `__call` runs like a function. The handler receives the host table plus call arguments. It suits factories, matchers, and state machines that read naturally as calls.

Run this pure snippet in the Command Bar.

```luau
--!strict
local adder = setmetatable({step = 5}, {
	__call = function(host, x)
		return x + host.step
	end,
})
print(adder(10))
```

```txt
15
```

Calling `adder` invokes the handler with 10. It adds the stored step and returns 15. Reserve callable tables for one clear action. A table with five behaviors should expose five named methods instead.

## \_\_tostring controls print output

`print` and `tostring` consult `__tostring` when present. The handler receives the host table and returns a string. It suits logs, errors, and debug views. Keep the output short and stable so tests can match it.

Run this pure snippet in the Command Bar.

```luau
--!strict
local pt = setmetatable({x = 3, y = 4}, {
	__tostring = function(host)
		return `{host.x},{host.y}`
	end,
})
print(pt)
```

```txt
3,4
```

Printing the table calls your handler. It formats both fields with backtick interpolation from [strings](https://create.roblox.com/docs/luau/strings). Show values that identify the object. Leave out secrets or huge dumps.

## \_\_add teaches plus to tables

Arithmetic metamethods give operators to tables. `__add` handles `+`. It receives both operands in order. Return a fresh value rather than mutating inputs. Sibling metamethods cover the rest of the operators listed in [metatables](https://create.roblox.com/docs/en-us/luau/metatables).

Run this pure snippet in the Command Bar.

```luau
--!strict
local mt = {
	__add = function(a, b)
		return {x = a.x + b.x, y = a.y + b.y}
	end,
}
local p = setmetatable({x = 1, y = 2}, mt)
local q = setmetatable({x = 10, y = 20}, mt)
local r = p + q
print(r.x)
print(r.y)
```

```txt
11
22
```

Adding two points calls the shared handler. It sums each field into a new table. The inputs stay unchanged. Add operators only when the math reads better than a named function. Positions and colors qualify. Game entities do not.

## \_\_len customizes the length operator

Luau fires `__len` when code applies `#` to the host. The handler returns the reported length. It suits custom containers such as sets and queues where `#host` should mean count, not array boundary.

Run this pure snippet in the Command Bar.

```luau
--!strict
local bag = setmetatable({sword = true, bow = true}, {
	__len = function(host)
		local n = 0
		for _ in host do
			n += 1
		end
		return n
	end,
})
print(#bag)
```

```txt
2
```

The handler counts every key with generalized iteration and returns 2. Without it, `#` on a dictionary would report 0. Document custom lengths clearly because readers expect `#` to mean array size.

## \_\_eq gives shared types fair equality

Luau fires `__eq` for `==` only when both operands share the same metamethod function and the same basic type. Different handlers mean plain identity comparison. This rule stops accidental equality across unrelated types. The equality footnote in [metatables](https://create.roblox.com/docs/en-us/luau/metatables) states the requirement.

Run this pure snippet in the Command Bar.

```luau
--!strict
local same = {
	__eq = function(a, b)
		return a.id == b.id
	end,
}
local a = setmetatable({id = 5}, same)
local b = setmetatable({id = 5}, same)
local c = setmetatable({id = 5}, {})
print(a == b)
print(a == c)
```

```txt
true
false
```

`a` and `b` share one handler, so their ids compare and return true. `c` carries a different metatable, so Luau falls back to identity and returns false. Define `__eq` for value types like points and item stacks. Skip it for entities where identity is the point.

## Methods take self first

A method is a function stored in a table that expects the host as its first parameter, named `self`. Colon syntax passes the host automatically. `obj:M()` means `obj.M(obj)`. Dot syntax needs the argument spelled out. Definitions follow the same rule: `function obj:M()` means `function obj.M(self)`. The method guide in [functions](https://create.roblox.com/docs/luau/functions) shows both directions.

Run this pure snippet in the Command Bar.

```luau
--!strict
local door = {open = false}
function door.Toggle(self)
	self.open = not self.open
	return self.open
end
print(door.Toggle(door))
print(door:Toggle())
```

```txt
true
false
```

The dot call passes `door` by hand and opens it. The colon call passes `door` automatically and closes it. Define with colon and call with colon for methods. Define with dot and call with dot for plain functions. Mixing shapes is the top method bug in reviews.

## Class-like tables use .new plus \_\_index

Luau has no class keyword. Class-like tables use three pieces: a class table holding methods, a `.new` constructor that builds instances, and an `__index` link from instances to the class. Each instance holds its own fields. Shared methods live once on the class.

Put this ModuleScript in ReplicatedStorage and name it Bank.

```luau
--!strict
local Bank = {}
Bank.__index = Bank
function Bank.new(owner: string): any
	local self = setmetatable({}, Bank)
	self.owner = owner
	self.coins = 0
	return self
end
function Bank:Add(n: number): number
	self.coins += n
	return self.coins
end
return Bank
```

Setting `Bank.__index` to `Bank` itself links instances to the class. The constructor makes an empty instance, links it, fills fields, and returns it. Method calls like `acct:Add(10)` find `Add` through the link. The instance holds `coins` while the class holds behavior. This is the standard Luau class shape.

Put this Script in ServerScriptService.

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Bank = require(ReplicatedStorage:WaitForChild("Bank"))
local a = Bank.new("Mara")
local b = Bank.new("Bo")
print(a:Add(10))
print(b:Add(5))
print(a.coins)
```

```txt
10
5
10
```

Two accounts start separately. Deposits move each balance alone. The shared method serves both through the metatable. Test constructors by proving independence exactly like this.

Keep inheritance shallow or absent. Deep chains hide where a method lives. Roblox data rarely needs more than one reuse level. When two classes share behavior, extract a helper module instead of a base class.

## Composition beats inheritance

Composition builds features from parts. An entity owns a mover, a health block, and a wallet. Each part is a small table with a tiny API. The entity forwards calls or exposes parts directly. Parts swap without rewriting the entity.

Run this pure snippet in the Command Bar.

```luau
--!strict
local function makeHealth(max: number): any
	return {hp = max, max = max}
end
local function damage(h: any, n: number): number
	h.hp = math.max(0, h.hp - n)
	return h.hp
end
local hero = {name = "Mara", health = makeHealth(100)}
print(damage(hero.health, 30))
print(hero.health.hp)
```

```txt
70
70
```

Health is a plain part with two fields. `damage` is a helper that clamps at 0. The hero owns the part but shares no metatable with it. Health tests alone, reuses for pets and doors, and replaces without touching movement code. That swap cost is why teams prefer composition.

No metatable is needed for composition. Plain tables plus functions carry most designs. Add a metatable only when fallback lookup, call syntax, or operators earn their complexity. Simple tables from [tables](https://create.roblox.com/docs/luau/tables) stay the default.

## Prototype style clones a model

Prototype design clones a model object instead of calling a constructor. Build one exemplar with good defaults. Shallow- or deep-copy it for each use, then tweak fields. `table.clone` suffices for flat models. Nested models need the deep copy from [Tables in Depth](./tables-in-depth.md).

Run this pure snippet in the Command Bar.

```luau
--!strict
local goblin = {hp = 30, speed = 12, kind = "goblin"}
local function spawn(from: any, hp: number): any
	local copy = table.clone(from)
	copy.hp = hp
	return copy
end
local a = spawn(goblin, 30)
local b = spawn(goblin, 45)
print(a.hp)
print(b.hp)
print(goblin.hp)
```

```txt
30
45
30
```

Each spawn clones the exemplar and overrides hit points. The model stays at 30 for future spawns. Prototypes suit enemies, loot, and projectiles with small variations. Switch back to constructors when setup logic outgrows copy plus tweak.

## Common mistakes

Wrong: you call a method with a dot and lose self.

```luau
--!strict
local box = {n = 1}
function box.Bump(self)
	self.n += 1
end
box.Bump()
print(box.n)
```

Studio reports an error about indexing nil because `self` is nil without the host argument. Fix it with a colon call or an explicit argument.

```luau
--!strict
local box = {n = 1}
function box:Bump()
	self.n += 1
end
box:Bump()
print(box.n)
```

```txt
2
```

Wrong: you set `__index` to a table you later mutate as shared state.

```luau
--!strict
local K = {}
K.__index = K
K.hp = 100
local m = setmetatable({}, K)
m.hp = 10
```

`m.hp` now shadows, but reads of other missing keys still hit the mutable class table. Fix it by keeping instance state on instances and shared behavior only on the class. Freeze class defaults when instances must not edit them.

Wrong: you expect `__eq` across different metatables.

```luau
--!strict
local a = setmetatable({id = 1}, {__eq = function(x, y)
	return x.id == y.id
end})
local b = setmetatable({id = 1}, {__eq = function(x, y)
	return x.id == y.id
end})
print(a == b)
```

```txt
false
```

Two literal handlers are different functions, so Luau uses identity. Fix it by sharing one metatable or one handler function between compared values.

Wrong: you write to a guarded table inside `__newindex` without rawset.

```luau
--!strict
local t = setmetatable({}, {
	__newindex = function(host, k, v)
		host[k] = v
	end,
})
t.x = 1
```

The assignment re-enters `__newindex` forever and overflows. Fix it with `rawset(host, k, v)` for the actual store.

## Exercises

1. Build an \_\_index fallback. Read one present key and one defaulted key.
2. Guard writes with \_\_newindex plus rawset. Log each new key.
3. Add \_\_tostring, \_\_add, \_\_len, and \_\_eq to small demo tables. Print each result.
4. Write a class-like Bank with .new and two methods. Prove two instances stay independent.
5. Compose an entity from health and wallet parts. Compare the code against a prototype clone version.

::: details Solution 1
Run this pure snippet in the Command Bar.

```luau
--!strict
local base = {hp = 50}
local unit = setmetatable({name = "Imp"}, {__index = base})
print(unit.name)
print(unit.hp)
```

```txt
Imp
50
```

Present keys read directly. Missing keys fall back to the base table.
:::

::: details Solution 2
Run this pure snippet in the Command Bar.

```luau
--!strict
local seen = {}
local p = setmetatable({}, {
	__newindex = function(host, k, v)
		table.insert(seen, k)
		rawset(host, k, v)
	end,
})
p.a = 1
p.b = 2
print(table.concat(seen, ","))
print(p.a + p.b)
```

```txt
a,b
3
```

Each new write logs its key once. Rawset stores without re-entering the guard.
:::

::: details Solution 3
Run this pure snippet in the Command Bar.

```luau
--!strict
local mt = {}
function mt.__tostring(host)
	return `v{host.n}`
end
function mt.__add(a, b)
	return setmetatable({n = a.n + b.n}, mt)
end
function mt.__len(host)
	return host.n
end
function mt.__eq(a, b)
	return a.n == b.n
end
mt.__index = mt
local a = setmetatable({n = 2}, mt)
local b = setmetatable({n = 3}, mt)
print(a)
print((a + b).n)
print(if #a == 2 then "len ok" else "len bad")
print(a == b)
print(a == setmetatable({n = 2}, mt))
```

```txt
v2
5
len ok
false
true
```

Tostring formats, add sums into a fresh value, len reports `n`, and equality needs the shared handler plus matching values.
:::

::: details Solution 4
Run this pure snippet in the Command Bar.

```luau
--!strict
local Coin = {}
Coin.__index = Coin
function Coin.new(): any
	return setmetatable({n = 0}, Coin)
end
function Coin:Add(x: number): number
	self.n += x
	return self.n
end
function Coin:Total(): number
	return self.n
end
local a = Coin.new()
local b = Coin.new()
a:Add(4)
print(a:Total())
print(b:Total())
```

```txt
4
0
```

Two instances share methods through one link. Their `n` fields move independently.
:::

::: details Solution 5
Run this pure snippet in the Command Bar.

```luau
--!strict
local model = {hp = 20, coins = 0}
local function spawn(hp: number): any
	local c = table.clone(model)
	c.hp = hp
	return c
end
local x = spawn(20)
local y = spawn(35)
print(x.hp)
print(y.hp)
```

```txt
20
35
```

Clones vary by field overrides with no constructor. This suits setup that is copy plus tweak, while constructors suit setup that needs validation and wiring.
:::

## Summary

* Metatables add fallback, guard, call, print, math, length, and equality behavior.
* Colon syntax passes self. Dot syntax needs it by hand.
* Class-like tables pair .new with an \_\_index link. Instances hold state.
* Composition and prototypes beat deep inheritance for most game code.
* Next: [Errors and Defensive Code](./errors-and-defensive-code.md).
