Skip to main content

Type Annotations Deep Dive

Learning Objectives

  • Annotate script state with export type
  • Use Input<T> for editor-exposed values
  • Apply late() for engine-provided values
  • Balance explicit vs. inferred types

AE/JS Syntax Comparison

If you're coming from After Effects expressions or JavaScript, here's how Rive's annotation patterns compare:

ConceptJavaScript/AELuau (Rive)Notes
Object type{ name: string } (TS){ name: string }Same syntax!
Export typeexport type X = ... (TS)export type X = ...Same syntax!
Self referencethis.healthself.healthLuau uses self, not this
Class propertiesDeclared in constructorDeclared in export typeDifferent pattern entirely
Input bindingN/AInput<number>Rive-specific wrapper
Deferred initN/Alate()Rive-specific placeholder
Factory functionnew Class()return function(): Node<T>Rive's script pattern
Key Insight

In JavaScript, you typically use classes with constructors. In Rive, you use an export type to define your data shape, then a factory function that returns the initial state. This is a functional pattern, not an object-oriented one.


Rive Context

Rive scripts support --!strict mode for type safety. While scripts can run without it, strict mode is strongly recommended.

Why does this matter? The Rive editor uses your export type to understand what properties your script has. This enables autocomplete, error checking, and proper input binding.

Why use strict typing? Rive scripts run inside an editor and runtime that should be safe and predictable. Strict typing prevents ambiguous state and catches errors before you hit Play, which is helpful when scripts drive animation or user interaction.

Key rules in Rive:

  • Use export type to describe your script instance (self)
  • Use Input<T> for values exposed to the editor
  • Use late() when a value is provided by the engine after creation

The Standard Node Script Template

Every Rive Node Script follows this pattern:

--!strict

export type MyScript = {}

function init(self: MyScript): boolean
return true
end

function draw(self: MyScript, renderer: Renderer)
end

return function(): Node<MyScript>
return {
init = init,
draw = draw,
}
end

Key elements:

  1. --!strict - Enables type checking
  2. export type - Defines the shape of self
  3. function init(self: MyScript) - The self parameter is annotated
  4. Node<MyScript> - The factory return type uses your script type
Why the init(self: T): boolean syntax?

Luau doesn’t use classes for scripts. The engine calls plain functions and passes the script instance in as self. The : boolean return type is required so Rive can enable/disable the script based on success (return true keeps it running). If you’re coming from JavaScript, read it as: function init(self /*: MyScript */): boolean.

JavaScript/TypeScript Equivalent (conceptually):

// In TypeScript, you'd use a class:
class MyScript {
init(): boolean {
return true;
}
draw(renderer: Renderer): void {
}
}

// In Rive, you use:
// 1. A type definition (export type MyScript = {...})
// 2. Standalone functions that take self
// 3. A factory function that returns the initial state

Practice Exercises

Exercise 1: Exported State Shape ⭐

Premise

Your export type is the contract for your script state. When the shape is clear, you can trust every field and the editor can catch mistakes early.

Goal

By the end of this exercise, you will define a typed state shape and initialize it.

Use Case

You are building a small overlay with a label, opacity, and enabled flag. These values are used by multiple functions, so the type definition must be explicit.

Example scenarios:

  • UI overlays with labels and toggles
  • Shared state between init and draw

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise1_StateShape
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise1 = {
label: string,
opacity: number,
enabled: boolean,
}

function init(self: Exercise1): boolean
-- TODO: Set the state fields to the correct values
self.label = ""
self.opacity = 0
self.enabled = false

print(`ANSWER: label={self.label},opacity={self.opacity},enabled={self.enabled}`)
return true
end

function draw(self: Exercise1, renderer: Renderer)
end

return function(): Node<Exercise1>
return {
init = init,
draw = draw,
label = "",
opacity = 0,
enabled = false,
}
end

Assignment

Complete these tasks:

  1. Set label to Glow
  2. Set opacity to 0.8
  3. Set enabled to true

Expected Output

Your console output should display the three annotated field values demonstrating proper type annotations.


Verify Your Answer

Verify Your Answer

Checklist

  • Fields match their annotated types
  • Values match the assignment
  • Output matches the expected line

Exercise 2: Typed Range Table ⭐

Premise

Table types let you describe structured data with clarity. Even small structs like ranges and bounds benefit from explicit annotations.

Goal

By the end of this exercise, you will annotate a table with a custom type.

Use Case

You are clamping motion in a small range before driving a Rive animation. A typed range table keeps min and max consistent.

Example scenarios:

  • Clamping progress values
  • Defining numeric ranges for inputs

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise2_TypedRange
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise2 = {}

type Range = {
min: number,
max: number,
}

function init(self: Exercise2): boolean
-- TODO: Set min to 3 and max to 15
local bounds: Range = {
min = 0,
max = 0,
}

local range = bounds.max - bounds.min
print(`ANSWER: range={range}`)
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 min to 3 and max to 15
  2. Keep bounds typed as Range

Expected Output

Your console output should display the computed range (max minus min) from the typed table.


Verify Your Answer

Verify Your Answer

Checklist

  • Range table uses the correct type
  • Min and max are correct
  • Output matches the expected line

Exercise 3: Input Defaults ⭐

Premise

Input<T> values are editor-controlled and intended to be read-only in scripts. You provide defaults in the factory, then read them in init.

Goal

By the end of this exercise, you will declare Input<T> fields and read them safely.

Use Case

A designer tweaks speed and enablement from the editor. Your script reads those values each run without mutating them.

Example scenarios:

  • Adjustable speed or opacity sliders
  • Toggleable effects in the editor

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise3_InputDefaults
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise3 = {
speed: Input<number>,
enabled: Input<boolean>,
}

function init(self: Exercise3): boolean
local speed = self.speed
local enabled = self.enabled
local boost = speed * (enabled and 2 or 1)

print(`ANSWER: boost={boost}`)
return true
end

function draw(self: Exercise3, renderer: Renderer)
end

return function(): Node<Exercise3>
return {
init = init,
draw = draw,
speed = 1.5,
enabled = true,
}
end

Assignment

Complete these tasks:

  1. Keep speed as an Input<number>
  2. Keep enabled as an Input<boolean>
  3. Use the provided defaults (1.5 and true)

Expected Output

Your console output should display the boost value computed from the Input fields (speed × 2 when enabled).


Verify Your Answer

Verify Your Answer

Checklist

  • Input fields have direct defaults
  • Values are read, not assigned
  • Output matches the expected line

Exercise 4: Input to State ⭐⭐

Premise

Input values are read-only, but your script can derive writable state from them. This pattern keeps editor controls clean while letting you compute internal values.

Goal

By the end of this exercise, you will derive typed state from Input<T> fields.

Use Case

A speed input comes from the editor, but your script stores a smoothed version for use during animation.

Example scenarios:

  • Smoothed speed values
  • Precomputed thresholds

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise4_InputToState
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise4 = {
speed: Input<number>,
smoothed: number,
}

function init(self: Exercise4): boolean
-- TODO: Compute a smoothed value from speed
self.smoothed = 0

print(`ANSWER: smoothed={self.smoothed}`)
return true
end

function draw(self: Exercise4, renderer: Renderer)
end

return function(): Node<Exercise4>
return {
init = init,
draw = draw,
speed = 2.0,
smoothed = late(),
}
end

Assignment

Complete these tasks:

  1. Multiply speed by 0.9
  2. Store the result in self.smoothed

Expected Output

Your console output should display the smoothed value derived from the input speed.


Verify Your Answer

Verify Your Answer

Checklist

  • Input is read-only
  • smoothed is computed in init
  • Output matches the expected line

Exercise 5: Input Label to Status ⭐⭐

Premise

String inputs are common for labels and identifiers. A typed status string ensures you do not mix raw input with computed output.

Goal

By the end of this exercise, you will derive a typed string from an input label.

Use Case

A designer supplies a label in the editor. Your script adds a status suffix before displaying it.

Example scenarios:

  • Label formatting for HUD elements
  • Status text in a State Machine

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise5_LabelStatus
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise5 = {
label: Input<string>,
status: string,
}

function init(self: Exercise5): boolean
-- TODO: Create status as "<label> ready"
self.status = ""

print(`ANSWER: status={self.status}`)
return true
end

function draw(self: Exercise5, renderer: Renderer)
end

return function(): Node<Exercise5>
return {
init = init,
draw = draw,
label = "Orbit",
status = late(),
}
end

Assignment

Complete these tasks:

  1. Read the input label from self.label
  2. Set self.status to the label followed by " ready"

Expected Output

Your console output should display the formatted status string combining the input label with a suffix.


Verify Your Answer

Verify Your Answer

Checklist

  • label is an Input<string>
  • status is computed in init
  • Output matches the expected line

Exercise 6: Function Field Annotation ⭐⭐

Premise

Functions can be stored as fields, and their signatures should be typed too. This makes it clear how the function should be called elsewhere in the script.

Goal

By the end of this exercise, you will annotate a function field in a state type.

Use Case

You want a reusable formatter that converts a label and value into a single string used across multiple prints or UI labels.

Example scenarios:

  • Label formatting helpers
  • Reusable debugging output

Setup

In Rive Editor:

  1. Create the script:

    • Assets panel → + → Script → Node Script
    • Name it Exercise6_FunctionField
  2. Attach and run:

    • Attach to any shape and press Play

Starter Code

--!strict

export type Exercise6 = {
format: (label: string, value: number) -> string,
}

function init(self: Exercise6): boolean
local result = self.format("Glow", 4)
print(`ANSWER: {result}`)
return true
end

function draw(self: Exercise6, renderer: Renderer)
end

return function(): Node<Exercise6>
return {
init = init,
draw = draw,
format = function(label: string, value: number): string
-- TODO: Return "label:value" with a colon
return ""
end,
}
end

Assignment

Complete these tasks:

  1. Return label .. ":" .. value from format
  2. Keep the function signature typed

Expected Output

Your console output should display the formatted result from the typed function field.


Verify Your Answer

Verify Your Answer

Checklist

  • Function field has a typed signature
  • Formatter returns the correct string
  • Output matches the expected line

Knowledge Check

Q:When should you use late() in the factory function?
Q:What's the difference between Input<number> and number?
Q:Why do empty tables need explicit type annotations in strict mode?
Q:What annotation syntax is used for optional (nilable) types?

Common Mistakes

Avoid These Errors
  1. Forgetting --!strict: Add it near the top of your file (recommended)

  2. Using plain tables for Rive types:

    -- WRONG
    self.position = {x = 100, y = 50}

    -- CORRECT
    self.position = Vector.xy(100, 50)
  3. Missing late(): Properties set in init() need late() in the factory

    -- Factory for properties set in init()
    return {
    path = late(), -- CORRECT: set in init()
    paint = late(), -- CORRECT: set in init()
    speed = 100, -- CORRECT: Input default
    }
  4. Using .value on Inputs:

    -- WRONG: .value does not exist on inputs here
    local x = self.speed.value * 2

    -- CORRECT: Read the input directly
    local x = self.speed * 2
  5. Empty tables without types:

    -- WRONG
    local t = {}

    -- CORRECT
    local t: {string} = {}

Self-Assessment Checklist

  • I can write the standard Node Script template from memory
  • I understand when to use late() vs direct values
  • I can annotate helper functions with parameters and return types
  • I know the difference between Input<T> and T
  • I use Rive's built-in types correctly (Vector, Color, Path, Paint)

Next Steps