Lesson 2.1: Type Annotations & Inference
Learning Objectives
- Enable and understand
--!strictmode - Add type annotations to variables and functions
- Understand type inference and when to rely on it
- Use type refinement with control flow
AE/JS Syntax Comparison
If you're coming from After Effects expressions or JavaScript, here's how Luau's type system compares:
| Concept | JavaScript/AE | Luau (Rive) | Notes |
|---|---|---|---|
| Variable with type | let x = 5 (inferred) | local x: number = 5 | Luau uses : for type annotations |
| Function parameters | function foo(x) {} | function foo(x: number) | Parameters need explicit types |
| Return type | Implicit | function foo(): number | Return types come after ) |
| Type checking mode | None (runtime only) | --!strict | Luau catches errors at compile time |
| Optional type | x?: number (TypeScript) | x: number? | ? comes AFTER the type name |
| Union type | x: number | string (TS) | x: number | string | Same syntax! |
| Type alias | type X = number (TS) | type X = number | Same syntax! |
| Interface/Object type | { name: string } (TS) | { name: string } | Same syntax! |
| Any type | any (TS) | any | Avoid when possible |
JavaScript has NO compile-time type checking. Errors happen at runtime when your code runs.
Luau with --!strict catches type errors BEFORE your code runs. This is why Rive requires strict mode—bugs are caught during development, not when users see your animation.
Rive Context
Rive scripts support --!strict mode for type safety. While scripts can run without it, strict mode is strongly recommended because it catches type errors during development.
Why does this matter? When your animation runs on a user's device, there's no debugger. Strict mode catches bugs during development so your animation works reliably everywhere.
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
Why Types Matter
In Module 1, you wrote code that worked—but how do you know it won't break when you change something? Types act as guardrails:
- Catch errors early: Before your animation even runs
- Better autocomplete: The editor knows what methods are available
- Self-documenting code: Types explain what data flows where
- Rive integration: Rive generates types for its entire API
Mode Directives
--!strict -- Full type checking (recommended for Rive)
--!nonstrict -- Partial checking, allows 'any' inference
--!nocheck -- No type checking (default if no directive)
Rive scripts run in strict mode by default—you don't need to add --!strict explicitly. However, adding it doesn't hurt and makes the intent clear to other developers.
Basic Type Annotations
local age: number = 25
local name: string = "Hero"
local active: boolean = true
local function greet(name: string): string
return `Hello, {name}!`
end
JavaScript Equivalent:
// JavaScript (no type checking)
let age = 25;
let name = "Hero";
let active = true;
function greet(name) {
return `Hello, ${name}!`;
}
// TypeScript (similar to Luau)
let age: number = 25;
let name: string = "Hero";
let active: boolean = true;
function greet(name: string): string {
return `Hello, ${name}!`;
}
Type Inference
Luau can often infer types automatically:
local score = 100 -- Luau infers: number
local items = {"a", "b"} -- Luau infers: {string}
Why This Matters: You don't need to annotate every single variable. Luau is smart enough to figure out obvious types. But for function parameters and complex structures, explicit types prevent errors.
Practice Exercises
Exercise 1: Strict Mode Guard ⭐
Premise
Strict mode is your safety net. It forces you to keep numeric math numeric and prevents quiet type coercion mistakes.
By the end of this exercise, you will use strict mode to keep a numeric calculation type-safe.
Use Case
You are calculating remaining health for a character driven by a Rive State Machine. If a number becomes a string, your math breaks and the animation desyncs. Strict mode makes sure the error is caught before you press Play.
Example scenarios:
- Health or stamina bars
- Progress and cooldown timers
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise1_StrictModeGuard
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise1 = {}
function init(self: Exercise1): boolean
local maxHealth: number = 100
local damage: number = 0
-- TODO: Set damage to 25 (keep it a number)
local remaining = maxHealth - damage
print(`ANSWER: remaining={remaining}`)
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
damageto 25 - Keep the subtraction numeric (no strings)
Expected Output
Your console output should display the remaining health after applying damage. The function should subtract damage from health and return the result.
Verify Your Answer
Checklist
-
--!strictis at the top -
damageis a number - Output matches the expected line
Exercise 2: Typed Variables ⭐
Premise
Typed variables are the backbone of readable, reliable scripts. They communicate intent to you and enforce correctness for the editor.
By the end of this exercise, you will annotate variables with explicit types.
Use Case
You are preparing a small state payload for a HUD: a label, a numeric level, and an on/off flag. Keeping the types clear prevents accidentally mixing text and numbers in your UI formatting.
Example scenarios:
- Labels that combine text and numbers
- Feature toggles for UI states
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise2_TypedVariables
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise2 = {}
function init(self: Exercise2): boolean
-- TODO: Add explicit types to each variable
local label: string = "Pulse"
local level: number = 7
local active: boolean = true
print(`ANSWER: label={label},level={level},active={active}`)
return true
end
function draw(self: Exercise2, renderer: Renderer)
end
return function(): Node<Exercise2>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Ensure each variable has the correct type annotation
- Keep the values exactly as shown
Expected Output
Your console output should display three fields from the typed variables:
label=— the string valuelevel=— the number valueactive=— the boolean value
Verify Your Answer
Checklist
- label is a string
- level is a number
- active is a boolean
Exercise 3: Typed Function ⭐
Premise
Functions are the most common place where type errors hide. Clear parameter and return types make it obvious how a helper should be used.
By the end of this exercise, you will implement a typed helper function.
Use Case
You are scaling an opacity value before drawing a layer. The helper should only accept numbers and always return a number for the Renderer.
Example scenarios:
- Scaling opacity or alpha
- Converting Input ranges
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise3_TypedFunction
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise3 = {}
local function scaleOpacity(value: number, multiplier: number): number
-- TODO: Return the scaled value
return 0
end
function init(self: Exercise3): boolean
local scaled = scaleOpacity(0.8, 0.75)
print(`ANSWER: opacity={scaled}`)
return true
end
function draw(self: Exercise3, renderer: Renderer)
end
return function(): Node<Exercise3>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Multiply value by multiplier in
scaleOpacity - Leave the function signature unchanged
Expected Output
Your console output should display the computed opacity value after applying the multiplier to the base value.
Verify Your Answer
Checklist
- Parameters are typed as numbers
- Return type is number
- Output matches the expected line
Exercise 4: Inference Snapshot ⭐
Premise
You do not need to annotate every variable. Luau can infer obvious types, which keeps your code concise while staying safe.
By the end of this exercise, you will rely on type inference for local values.
Use Case
You are collecting a few inferred values before pushing them into a typed API call. Seeing the inferred types helps you trust the compiler when the values are obvious.
Example scenarios:
- Quick debug summaries
- Temporary values during calculations
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise4_InferenceSnapshot
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise4 = {}
function init(self: Exercise4): boolean
local label = "Pulse"
local frames = 12
local weights = {0.1, 0.2, 0.3}
-- TODO: Build the summary string using type() and #weights
local summary = ""
print(`ANSWER: {summary}`)
return true
end
function draw(self: Exercise4, renderer: Renderer)
end
return function(): Node<Exercise4>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- Use
type(label)andtype(frames) - Use
type(weights[1])and#weights - Format as
label=string,frames=number,weight=number,len=3
Expected Output
Your console output should display the inferred types of the variables using type() and the length of the weights array. This demonstrates Luau's type inference.
Verify Your Answer
Checklist
- Inferred types match the values
- Summary uses type() and #weights
- Output matches the expected line
Exercise 5: Type Refinement ⭐⭐
Premise
Union types are common when data can arrive in multiple forms. Refinement lets you safely handle each case without losing strict mode benefits.
By the end of this exercise, you will refine a string | number union.
Use Case
An input may arrive as text from a UI field or as a number from a state machine. You need a single numeric value for calculations, so you refine the type before using it.
Example scenarios:
- Parsing text inputs
- Accepting multiple input formats
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise5_TypeRefinement
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise5 = {}
function init(self: Exercise5): boolean
local raw: string | number = "3"
local value = 0
-- TODO: Refine raw into a number and store in value
local doubled = value * 2
print(`ANSWER: doubled={doubled}`)
return true
end
function draw(self: Exercise5, renderer: Renderer)
end
return function(): Node<Exercise5>
return {
init = init,
draw = draw,
}
end
Assignment
Complete these tasks:
- If
rawis a string, convert it withtonumber - If
rawis already a number, use it directly - Keep
valueas a number
Expected Output
Your console output should display the doubled value after refining the union type and converting string to number if needed.
Verify Your Answer
Checklist
- raw is refined with a type check
- value is a number before doubling
- Output matches the expected line
Exercise 6: Rive State Type ⭐⭐
Premise
Typed state makes Rive scripts easier to reason about. When you declare fields, the editor can catch missing or mistyped values early.
By the end of this exercise, you will define a typed state payload for a script.
Use Case
You are tracking animation progress and a UI label inside a Node Script. A typed state ensures that progress is always numeric and the label is always text.
Example scenarios:
- Progress and label pairs
- Typed state for HUD elements
Setup
In Rive Editor:
-
Create the script:
- Assets panel →
+→ Script → Node Script - Name it
Exercise6_RiveStateType
- Assets panel →
-
Attach and run:
- Attach to any shape and press Play
Starter Code
--!strict
export type Exercise6 = {
progress: number,
label: string,
playing: boolean,
}
function init(self: Exercise6): boolean
-- TODO: Set state fields to the correct values
self.progress = 0
self.label = ""
self.playing = false
print(`ANSWER: label={self.label},progress={self.progress},playing={self.playing}`)
return true
end
function draw(self: Exercise6, renderer: Renderer)
end
return function(): Node<Exercise6>
return {
init = init,
draw = draw,
progress = 0,
label = "",
playing = false,
}
end
Assignment
Complete these tasks:
- Set label to
Orbit - Set progress to
0.75 - Set playing to
true
Expected Output
Your console output should display the three state fields with the assigned values, demonstrating typed state management.
Verify Your Answer
Checklist
- State fields match their types
- Output uses the updated values
- Output matches the expected line
Knowledge Check
Common Mistakes
-
Forgetting
--!strict: Add it near the top of your file (recommended)-- WRONG: No directive
export type MyScript = {}
-- CORRECT: Strict mode enabled
--!strict
export type MyScript = {} -
Empty tables without types:
-- WRONG
local t = {}
-- CORRECT
local t: {string} = {} -
Wrong Rive constructors:
-- WRONG
self.position = {x = 100, y = 50}
-- CORRECT
self.position = Vector.xy(100, 50) -
Not checking optionals:
-- WRONG: email might be nil
sendEmail(user.email)
-- CORRECT: Check first
if user.email then
sendEmail(user.email)
end -
Redundant annotations: Don't annotate when inference is obvious
-- Unnecessary (Luau knows it's number)
local x: number = 5
-- Better (let Luau infer)
local x = 5
Self-Assessment Checklist
- I understand Rive scripts run in strict mode by default
- I can annotate variables:
local x: number = 5 - I can annotate functions:
function(a: string): boolean - I understand when Luau can infer types automatically
- I can use type refinement with
ifchecks - I use Rive's built-in types correctly (Vector, Color, etc.)
Next Steps
- Continue to Type Annotations Deep Dive
- Need a refresher? Review Quick Reference