Lesson 2.2: Custom Types & Unions
Learning Objectives
- Define custom type aliases with the
typekeyword - Create table types (interfaces) for complex data
- Use union types for flexible values
- Understand and use optional types (
?) - Implement discriminated unions for state management
AE/JS Syntax Comparison
If you're coming from After Effects expressions or JavaScript/TypeScript, here's how custom types compare:
| Concept | JavaScript/TypeScript | Luau (Rive) | Notes |
|---|---|---|---|
| Type alias | type Age = number | type Age = number | Same syntax! |
| Object type | { name: string } | { name: string } | Same syntax! |
| Union type | string | number | string | number | Same syntax! |
| Optional property | email?: string | email: string? | ? placement differs! |
| Literal union | "up" | "down" | "up" | "down" | Same syntax! |
| Read-only | readonly x: number | read x: number | Different keyword |
| Intersection | A & B | A & B | Same syntax! |
-- TypeScript: ? comes BEFORE the colon
email?: string
-- Luau: ? comes AFTER the type name
email: string?
This is the most common source of confusion when coming from TypeScript!
Rive Context
In Rive scripts, your export type defines the shape of self. Custom types help keep complex Rive data (inputs, paths, runtime state) clean and safe.
Why does this matter? As your animations grow more complex, you need structured data. A character might have stats, inventory, and position. Custom types let you organize this cleanly.
Common Rive use cases for custom types:
- Nested state objects for complex animations
- Union types for State Machine states
- Optional types for late-bound references
- Type aliases for domain-specific values (angles, pixels, etc.)
Why Custom Types?
In Lesson 2.1, we used built-in types like number and string. But real applications need custom data structures:
- Player stats with multiple properties
- Configuration objects
- State machines with different states
- Data from ViewModels
Custom types give names to these structures, making code self-documenting and type-safe.
Type Aliases
Type aliases give meaningful names to types:
-- Type alias (gives a name to a type)
type Age = number
-- Table type (defines shape of data)
type Player = {
name: string,
health: number,
}
-- Union type (can be one of several types)
type StringOrNumber = string | number
-- Optional type (can be nil)
type MaybeString = string?
JavaScript/TypeScript Equivalent:
// TypeScript - almost identical!
type Age = number;
type Player = {
name: string;
health: number;
};
type StringOrNumber = string | number;
type MaybeString = string | null; // null instead of nil
Practice Exercises
Exercise 1: Stats Alias ⭐
Premise
Custom type aliases make repeated data shapes easy to reuse. You define the shape once and then rely on it everywhere.
By the end of this exercise, you will create and use a simple type alias.
Use Case
You track health and mana values for a Rive-driven HUD. A Stats type guarantees that both fields are present and numeric.
Example scenarios:
- Health/mana bars
- HUD state bundles
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_StatsAlias
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {}
type Stats = {
hp: number,
mp: number,
}
function init(self: Exercise1): boolean
-- TODO: Set hp to 90 and mp to 30
local stats: Stats = {
hp = 0,
mp = 0,
}
local total = stats.hp + stats.mp
print(`ANSWER: total={total}`)
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 hp to 90
- Set mp to 30
Expected Output
Your console output should display the total computed from the typed Stats table (hp + mp).
Verify Your Answer
Checklist
- Stats uses the alias type
- Values are correct
- Output matches the expected line
Exercise 2: Nested Types ⭐
Premise
Aliases can reference other aliases, letting you model nested state cleanly. This mirrors how real animation data is structured.
By the end of this exercise, you will compose types to represent nested data.
Use Case
You track a sprite name and its position. The position is its own reusable shape, so you define it as a separate type.
Example scenarios:
- Grouping transforms for layers
- Organizing nested state
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_NestedTypes
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise2 = {}
type Transform = {
x: number,
y: number,
}
type Sprite = {
name: string,
transform: Transform,
}
function init(self: Exercise2): boolean
-- TODO: Set name to "Beacon", x to 12, y to 18
local sprite: Sprite = {
name = "",
transform = {
x = 0,
y = 0,
},
}
local pos = sprite.transform.x + sprite.transform.y
print(`ANSWER: name={sprite.name},pos={pos}`)
return true
end
function draw(self: Exercise2, renderer: Renderer)
end
return function(): Node<Exercise2>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Set the sprite name to "Beacon"
- Set x to 12 and y to 18
Expected Output
Your console output should display the sprite name and position sum (x + y) from the nested types.
Verify Your Answer
Checklist
- Nested types are filled correctly
- Position sum equals 30
- Output matches the expected line
Exercise 3: Array Alias ⭐
Premise
Lists are common in animation scripts. Aliasing array types makes intent obvious and keeps APIs consistent.
By the end of this exercise, you will define and use an array type alias.
Use Case
You store timing offsets in a list and need a quick average. A typed array ensures every value is numeric.
Example scenarios:
- Timing offsets
- Sample lists
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_ArrayAlias
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {}
type Points = { number }
function init(self: Exercise3): boolean
-- TODO: Set the points to {2, 4, 6}
local points: Points = {0, 0, 0}
local total = points[1] + points[2] + points[3]
local avg = total / #points
print(`ANSWER: avg={avg}`)
return true
end
function draw(self: Exercise3, renderer: Renderer)
end
return function(): Node<Exercise3>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Replace the array values with 2, 4, 6
- Keep the type alias for the array
Expected Output
Your console output should display the average of the array values.
Verify Your Answer
Checklist
- Array values are correct
- Type alias is used
- Output matches the expected line
Exercise 4: Optional Field ⭐
Premise
Custom types can include optional fields. This lets you model data that might not always be present.
By the end of this exercise, you will handle an optional field from a type alias.
Use Case
A label may include an optional subtitle. If it is missing, you need a safe fallback.
Example scenarios:
- Optional subtitles
- Default metadata values
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_OptionalField
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {}
type Label = {
text: string,
subtitle: string?,
}
function init(self: Exercise4): boolean
local label: Label = {
text = "Signal",
subtitle = nil,
}
-- TODO: Use "None" when subtitle is nil
local subtitle = ""
print(`ANSWER: subtitle={subtitle}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Read the optional subtitle
- Use "None" as a fallback
Expected Output
Your console output should display the subtitle value — either the actual subtitle or the fallback when nil.
Verify Your Answer
Checklist
- Optional field is checked for nil
- Fallback value is used
- Output matches the expected line
Exercise 5: Function Alias ⭐⭐
Premise
Function aliases make reusable helpers easier to reason about. They also help you share signatures across multiple scripts.
By the end of this exercise, you will use a function type alias.
Use Case
You define an easing helper that is reused across animations. The alias makes the required signature explicit.
Example scenarios:
- Easing functions
- Reusable animation helpers
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_FunctionAlias
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise5 = {}
type EaseFn = (t: number) -> number
function init(self: Exercise5): boolean
local easeIn: EaseFn = function(t: number): number
-- TODO: Return t * t
return 0
end
local eased = easeIn(0.5)
print(`ANSWER: eased={eased}`)
return true
end
function draw(self: Exercise5, renderer: Renderer)
end
return function(): Node<Exercise5>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Return t * t in the easing function
- Keep the function typed as EaseFn
Expected Output
Your console output should display the eased value computed by the function alias (t squared).
Verify Your Answer
Checklist
- Ease function matches the alias
- Output equals 0.25
- Output matches the expected line
Exercise 6: State Alias ⭐⭐
Premise
You can embed custom types inside export type definitions. This keeps complex state organized without repeating fields.
By the end of this exercise, you will embed a custom type inside script state.
Use Case
You store meter data as a nested structure, then compute a percentage for UI rendering.
Example scenarios:
- Progress meters
- Battery or cooldown displays
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_StateAlias
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise6 = {
state: MeterState,
}
type MeterState = {
current: number,
max: number,
}
function init(self: Exercise6): boolean
-- TODO: Set current to 50 and max to 100
self.state = {
current = 0,
max = 0,
}
local percent = self.state.current / self.state.max
print(`ANSWER: percent={percent}`)
return true
end
function draw(self: Exercise6, renderer: Renderer)
end
return function(): Node<Exercise6>
return {
init = init,
draw = draw,
state = { current = 0, max = 0 },
}
end
Assignment
Complete these tasks:
- Set current to 50 and max to 100
- Keep the nested state typed as MeterState
Expected Output
Your console output should display the percent computed from the nested state alias (current / max).
Verify Your Answer
Checklist
- Nested state uses the alias
- Percent equals 0.5
- Output matches the expected line
Exercise 7: Config Alias ⭐⭐
Premise
Aliases make configuration objects easier to pass around without losing clarity. A typed config prevents mismatched fields.
By the end of this exercise, you will use a config alias for a small setup table.
Use Case
You store tuning values in a config table for a motion effect. Using a type alias keeps every script consistent.
Example scenarios:
- Motion tuning settings
- Reusable effect configs
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise7_ConfigAlias
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise7 = {}
type Config = {
speed: number,
enabled: boolean,
}
function init(self: Exercise7): boolean
-- TODO: Set speed to 1.2 and enabled to true
local config: Config = {
speed = 0,
enabled = false,
}
local output = config.enabled and config.speed or 0
print(`ANSWER: speed={output}`)
return true
end
function draw(self: Exercise7, renderer: Renderer)
end
return function(): Node<Exercise7>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Set speed to 1.2
- Set enabled to true
Expected Output
Your console output should display the speed value from the config alias when enabled is true.
Verify Your Answer
Checklist
- Config matches the alias
- Enabled gate works
- Output matches the expected line
Knowledge Check
Common Mistakes
-
Forgetting required properties: Table types require ALL non-optional properties
type Enemy = { name: string, health: number }
-- WRONG: Missing health
local enemy: Enemy = { name = "Goblin" }
-- CORRECT: All properties provided
local enemy: Enemy = { name = "Goblin", health = 30 } -
Not checking optionals: Always guard
string?before use-- WRONG: email might be nil
print(user.email:upper())
-- CORRECT: Check first
if user.email then
print(user.email:upper())
end -
Accessing union properties without narrowing: Check the type first
type Result = {success: true, data: string} | {success: false, error: string}
-- WRONG: Don't know which type yet
print(result.data)
-- CORRECT: Check first
if result.success then
print(result.data) -- Luau knows data exists
end -
Confusing
?and| nil: They're equivalent, but?is cleaner-- Both mean the same thing
email: string?
email: string | nil
Self-Assessment Checklist
- I can define custom type aliases
- I can create table types with multiple properties
- I can nest types inside other types
- I understand union types (
A | B) - I can handle optional types (
T?) safely - I can implement discriminated unions with
kindfields - I understand read-only properties
Next Steps
- Continue to Advanced Types
- Need a refresher? Review Quick Reference