Skip to main content

The __index Metamethod

Learning Objectives

  • Understand when and how __index is triggered
  • Use __index as both a table and a function
  • Master __newindex for controlling property assignment
  • Build lookup chains for inheritance hierarchies
  • Implement computed properties and lazy initialization

AE/JS Syntax Comparison

ConceptAfter EffectsJavaScriptLuau (Rive)
Property getterN/Aget prop() { return x; }__index function
Property setterN/Aset prop(v) { x = v; }__newindex function
Prototype chainN/AObject.getPrototypeOf()getmetatable(t).__index
Default valuesN/Aobj.prop ?? 'default'__index table with defaults
Computed propertyN/Aget area() { return w*h; }__index function returning computed value
Critical Difference: __index Only Fires for Missing Keys

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_IndexDefaults
  2. 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:

  1. Set style.opacity to 0.9
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_IndexComputed
  2. 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:

  1. Return sqrt(xx + yy)
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_LookupChain
  2. 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:

  1. Set Derived.kind to "Widget"
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_NewIndex
  2. 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:

  1. If value is a number above maxValue, store maxValue
  2. Otherwise store the original value

Expected Output

Your console output should display the clamped power value enforced by the __newindex metamethod.


Verify Your Answer

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_LazyInit
  2. 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:

  1. When a key is missing, store "created" in the table
  2. 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

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.

Goal

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:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise6_ReadOnly
  2. 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:

  1. Make __newindex ignore writes
  2. 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

Verify Your Answer

Checklist

  • Writes are ignored
  • Mode remains locked
  • Output matches the expected line

Knowledge Check

Q:When is __index called?
Q:What's the difference between __index as a table vs a function?
Q:Why use rawset inside __newindex?
Q:What happens when __index points to a table that also has __index?

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 __index is triggered (missing keys only)
  • I can use __index with both tables and functions
  • I can implement computed properties with function __index
  • I understand how lookup chains work through multiple levels
  • I can use __newindex for validation
  • I know when to use rawget and rawset
  • I can implement lazy initialization patterns

Next Steps