The __index Metamethod
Learning Objectives
- Understand when and how
__indexis triggered - Use
__indexas both a table and a function - Master
__newindexfor controlling property assignment - Build lookup chains for inheritance hierarchies
- Implement computed properties and lazy initialization
AE/JS Syntax Comparison
| Concept | After Effects | JavaScript | Luau (Rive) |
|---|---|---|---|
| Property getter | N/A | get prop() { return x; } | __index function |
| Property setter | N/A | set prop(v) { x = v; } | __newindex function |
| Prototype chain | N/A | Object.getPrototypeOf() | getmetatable(t).__index |
| Default values | N/A | obj.prop ?? 'default' | __index table with defaults |
| Computed property | N/A | get area() { return w*h; } | __index function returning computed value |
In JavaScript, getters run every time you access a property.
In Luau, __index is ONLY called when the key doesn't exist in the table.
local t = { x = 10 }
setmetatable(t, { __index = function() print("called!") end })
print(t.x) -- prints 10, NOT "called!"
print(t.y) -- NOW it prints "called!" because y doesn't exist
This is crucial for performance—methods and existing properties are fast.
Rive Context
In Rive Node Scripts, __index is your primary tool for:
- Sharing methods between drawable objects (particles, sprites, UI elements)
- Default values for style configuration (colors, thicknesses, fonts)
- Computed properties like bounding boxes derived from position and size
- Lazy loading expensive resources only when first accessed
Understanding __index deeply lets you build efficient, reusable Rive components.
When __index Fires
The __index metamethod is only called when a key doesn't exist in the table:
local t = { existing = "found" }
setmetatable(t, {
__index = function(tbl, key)
print(`Looking up: {key}`)
return "fallback"
end
})
print(t.existing) -- "found" (no __index call)
print(t.missing) -- prints "Looking up: missing", returns "fallback"
Key insight: If the key exists in the table, __index is never consulted.
Exercises
Exercise 1: __index Defaults ⭐
Premise
__index can point to a defaults table. This gives you fallback values without copying them into every instance.
By the end of this exercise, you will use __index as a table for defaults.
Use Case
You want default properties for UI elements, but allow per-instance overrides for specific values.
Example scenarios:
- Theme defaults
- Shared style settings
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_IndexDefaults
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {}
function init(self: Exercise1): boolean
local defaults = {
opacity = 0.4,
blur = 2,
}
-- TODO: Override opacity to 0.9 and use defaults for blur
local style = {
opacity = 0,
}
setmetatable(style, { __index = defaults })
print(`ANSWER: opacity={style.opacity},blur={style.blur}`)
return true
end
function draw(self: Exercise1, renderer: Renderer)
end
return function(): Node<Exercise1>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Set style.opacity to 0.9
- Keep __index pointing to defaults
Expected Output
Your console output should display the opacity value (overridden) and the blur value (from defaults via __index).
Verify Your Answer
Checklist
- Defaults supply blur
- Opacity override works
- Output matches the expected line
Exercise 2: __index Computed ⭐⭐
Premise
__index can be a function that computes values on demand. This is useful for derived properties.
By the end of this exercise, you will compute a property inside __index.
Use Case
You need to compute a derived value like length without storing it. __index can compute it only when needed.
Example scenarios:
- Vector length
- Derived UI measurements
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_IndexComputed
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise2 = {}
function init(self: Exercise2): boolean
local vec = { x = 6, y = 8 }
-- TODO: Compute length when key == "length"
setmetatable(vec, {
__index = function(t, key)
if key == "length" then
return 0
end
return nil
end,
})
print(`ANSWER: length={vec.length}`)
return true
end
function draw(self: Exercise2, renderer: Renderer)
end
return function(): Node<Exercise2>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Return sqrt(xx + yy)
- Keep the computed value inside __index
Expected Output
Your console output should display the computed length from the __index function (the hypotenuse of a 6-8-10 triangle).
Verify Your Answer
Checklist
- __index computes length
- Length equals 10
- Output matches the expected line
Exercise 3: Lookup Chain ⭐⭐
Premise
The __index chain can span multiple tables. This lets you build inheritance hierarchies for shared behavior.
By the end of this exercise, you will use a multi-level __index chain.
Use Case
You have a base widget with shared methods, then derived widgets add their own behavior. The lookup chain finds the right method.
Example scenarios:
- Base and derived UI widgets
- Shared helper methods
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_LookupChain
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {}
local Base = { kind = "Base" }
function Base:describe(): string
return `Kind={self.kind}`
end
local Derived = { kind = "Derived" }
setmetatable(Derived, { __index = Base })
function init(self: Exercise3): boolean
local instance = setmetatable({}, { __index = Derived })
-- TODO: Change Derived.kind to "Widget"
print(`ANSWER: {instance:describe()}`)
return true
end
function draw(self: Exercise3, renderer: Renderer)
end
return function(): Node<Exercise3>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Set Derived.kind to "Widget"
- Keep the lookup chain intact
Expected Output
Your console output should display the describe method result showing the kind value from the lookup chain.
Verify Your Answer
Checklist
- Lookup chain uses Base then Derived
- Kind resolves to Widget
- Output matches the expected line
Exercise 4: __newindex Validation ⭐⭐
Premise
__newindex lets you intercept assignments and enforce rules. This is useful for clamping or validation.
By the end of this exercise, you will validate assignments with __newindex.
Use Case
You want to ensure a value never exceeds a max threshold. __newindex can clamp values as they are assigned.
Example scenarios:
- Clamped style values
- Safe configuration assignment
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_NewIndex
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {}
function init(self: Exercise4): boolean
local config = {}
local maxValue = 10
setmetatable(config, {
__newindex = function(t, key, value)
-- TODO: Clamp numeric values to maxValue
rawset(t, key, value)
end,
})
config.power = 15
print(`ANSWER: power={config.power}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- If value is a number above maxValue, store maxValue
- Otherwise store the original value
Expected Output
Your console output should display the clamped power value enforced by the __newindex metamethod.
Verify Your Answer
Checklist
- __newindex clamps values
- power equals 10
- Output matches the expected line
Exercise 5: Lazy Initialization ⭐⭐
Premise
You can lazily create values when they are first accessed. __index is a common tool for this pattern.
By the end of this exercise, you will lazily initialize a missing key.
Use Case
You want to create an entry the first time it is requested, without pre-allocating everything.
Example scenarios:
- Lazy caches
- On-demand table entries
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_LazyInit
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise5 = {}
function init(self: Exercise5): boolean
local cache = {}
setmetatable(cache, {
__index = function(t, key)
-- TODO: Create and store a default value for missing keys
return nil
end,
})
local value = cache["token"]
print(`ANSWER: {value}`)
return true
end
function draw(self: Exercise5, renderer: Renderer)
end
return function(): Node<Exercise5>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- When a key is missing, store "created" in the table
- Return the stored value
Expected Output
Your console output should display the lazily initialized value that was created and stored on first access.
Verify Your Answer
Checklist
- Missing keys are initialized
- Value is stored and returned
- Output matches the expected line
Exercise 6: Read-Only Table ⭐⭐
Premise
You can prevent writes by intercepting __newindex. This is useful for read-only configuration.
By the end of this exercise, you will block writes with __newindex.
Use Case
You want a config table that should not be mutated at runtime. Writes are ignored so defaults stay intact.
Example scenarios:
- Immutable configuration
- Read-only settings
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_ReadOnly
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise6 = {}
function init(self: Exercise6): boolean
local config = {
mode = "locked",
}
setmetatable(config, {
-- TODO: Ignore any new assignments
__newindex = function(t, key, value)
end,
})
config.mode = "open"
print(`ANSWER: mode={config.mode}`)
return true
end
function draw(self: Exercise6, renderer: Renderer)
end
return function(): Node<Exercise6>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Make __newindex ignore writes
- Keep the original mode value
Expected Output
Your console output should display the original mode value, proving that writes were blocked by __newindex.
Verify Your Answer
Checklist
- Writes are ignored
- Mode remains locked
- Output matches the expected line
Knowledge Check
Common Mistakes
1. Forgetting rawset in __newindex
-- WRONG: Infinite recursion
setmetatable(t, {
__newindex = function(tbl, key, value)
tbl[key] = value -- This calls __newindex again!
end
})
-- CORRECT: Use rawset
setmetatable(t, {
__newindex = function(tbl, key, value)
rawset(tbl, key, value) -- Bypasses metamethod
end
})
2. Expecting __index for Existing Keys
-- WRONG assumption
local t = { name = "existing" }
setmetatable(t, {
__index = function() print("called!") end
})
print(t.name) -- Prints "existing", NOT "called!"
-- __index is NOT called because 'name' exists
3. Confusing __index Direction
-- WRONG: Parent points to child (backwards!)
local Parent = {}
local Child = {}
setmetatable(Parent, { __index = Child }) -- Wrong!
-- CORRECT: Child's metatable points to parent
local Parent = {}
local Child = {}
setmetatable(Child, { __index = Parent }) -- Right!
-- Now Child inherits from Parent
4. Over-using Function __index
-- OVERKILL: Using function when table works
setmetatable(obj, {
__index = function(t, k)
if k == "default1" then return 10
elseif k == "default2" then return 20
end
end
})
-- SIMPLER: Just use a table
local defaults = { default1 = 10, default2 = 20 }
setmetatable(obj, { __index = defaults })
Self-Assessment Checklist
- I understand when
__indexis triggered (missing keys only) - I can use
__indexwith both tables and functions - I can implement computed properties with function
__index - I understand how lookup chains work through multiple levels
- I can use
__newindexfor validation - I know when to use
rawgetandrawset - I can implement lazy initialization patterns
Next Steps
- Continue to 3.2 Building Classes
- Need a refresher? Review Quick Reference