Skip to main content

Lesson 2.2: Custom Types & Unions

Learning Objectives

  • Define custom type aliases with the type keyword
  • 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:

ConceptJavaScript/TypeScriptLuau (Rive)Notes
Type aliastype Age = numbertype Age = numberSame syntax!
Object type{ name: string }{ name: string }Same syntax!
Union typestring | numberstring | numberSame syntax!
Optional propertyemail?: stringemail: string?? placement differs!
Literal union"up" | "down""up" | "down"Same syntax!
Read-onlyreadonly x: numberread x: numberDifferent keyword
IntersectionA & BA & BSame syntax!
Critical Difference: Optional 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.

Goal

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:

  1. Create the script:

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

  1. Set hp to 90
  2. Set mp to 30

Expected Output

Your console output should display the total computed from the typed Stats table (hp + mp).


Verify Your Answer

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.

Goal

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:

  1. Create the script:

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

  1. Set the sprite name to "Beacon"
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Replace the array values with 2, 4, 6
  2. Keep the type alias for the array

Expected Output

Your console output should display the average of the array values.


Verify Your Answer

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.

Goal

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:

  1. Create the script:

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

  1. Read the optional subtitle
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Return t * t in the easing function
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Set current to 50 and max to 100
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Set speed to 1.2
  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

Verify Your Answer

Checklist

  • Config matches the alias
  • Enabled gate works
  • Output matches the expected line

Knowledge Check

Q:What's the difference between a type alias and a new type?
Q:What does 'string?' mean?
Q:Why use a 'kind' field in union types?
Q:What happens if you try to create a table missing required properties?

Common Mistakes

Avoid These Errors
  1. 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 }
  2. 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
  3. 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
  4. 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 kind fields
  • I understand read-only properties

Next Steps