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:
| Concept | JavaScript/AE | Luau (Rive) | Notes |
|---|---|---|---|
| Object type | { name: string } (TS) | { name: string } | Same syntax! |
| Export type | export type X = ... (TS) | export type X = ... | Same syntax! |
| Self reference | this.health | self.health | Luau uses self, not this |
| Class properties | Declared in constructor | Declared in export type | Different pattern entirely |
| Input binding | N/A | Input<number> | Rive-specific wrapper |
| Deferred init | N/A | late() | Rive-specific placeholder |
| Factory function | new Class() | return function(): Node<T> | Rive's script pattern |
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 typeto 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:
--!strict- Enables type checkingexport type- Defines the shape ofselffunction init(self: MyScript)- Theselfparameter is annotatedNode<MyScript>- The factory return type uses your script type
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_StateShape
- Assets panel →
-
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:
- Set label to
Glow - Set opacity to
0.8 - Set enabled to
true
Expected Output
Your console output should display the three annotated field values demonstrating proper type annotations.
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_TypedRange
- Assets panel →
-
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:
- Set
minto 3 andmaxto 15 - Keep
boundstyped asRange
Expected Output
Your console output should display the computed range (max minus min) from the typed table.
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_InputDefaults
- Assets panel →
-
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:
- Keep speed as an
Input<number> - Keep enabled as an
Input<boolean> - 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_InputToState
- Assets panel →
-
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:
- Multiply speed by 0.9
- Store the result in
self.smoothed
Expected Output
Your console output should display the smoothed value derived from the input speed.
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_LabelStatus
- Assets panel →
-
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:
- Read the input label from
self.label - Set
self.statusto 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
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.
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:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_FunctionField
- Assets panel →
-
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:
- Return
label .. ":" .. valuefrom format - Keep the function signature typed
Expected Output
Your console output should display the formatted result from the typed function field.
Verify Your Answer
Checklist
- Function field has a typed signature
- Formatter returns the correct string
- Output matches the expected line
Knowledge Check
Common Mistakes
-
Forgetting
--!strict: Add it near the top of your file (recommended) -
Using plain tables for Rive types:
-- WRONG
self.position = {x = 100, y = 50}
-- CORRECT
self.position = Vector.xy(100, 50) -
Missing
late(): Properties set ininit()needlate()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
} -
Using
.valueon 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 -
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>andT - I use Rive's built-in types correctly (Vector, Color, Path, Paint)
Next Steps
- Continue to Strict Mode Deep Dive
- Need a refresher? Review Quick Reference