Skip to main content

Lesson 2.1: Type Annotations & Inference

Learning Objectives

  • Enable and understand --!strict mode
  • 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:

ConceptJavaScript/AELuau (Rive)Notes
Variable with typelet x = 5 (inferred)local x: number = 5Luau uses : for type annotations
Function parametersfunction foo(x) {}function foo(x: number)Parameters need explicit types
Return typeImplicitfunction foo(): numberReturn types come after )
Type checking modeNone (runtime only)--!strictLuau catches errors at compile time
Optional typex?: number (TypeScript)x: number?? comes AFTER the type name
Union typex: number | string (TS)x: number | stringSame syntax!
Type aliastype X = number (TS)type X = numberSame syntax!
Interface/Object type{ name: string } (TS){ name: string }Same syntax!
Any typeany (TS)anyAvoid when possible
Critical Difference

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 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

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:

  1. Catch errors early: Before your animation even runs
  2. Better autocomplete: The editor knows what methods are available
  3. Self-documenting code: Types explain what data flows where
  4. 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)
Strict Mode is Default in Rive

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.

Goal

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:

  1. Create the script:

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

  1. Set damage to 25
  2. 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

Verify Your Answer

Checklist

  • --!strict is at the top
  • damage is 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.

Goal

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:

  1. Create the script:

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

  1. Ensure each variable has the correct type annotation
  2. Keep the values exactly as shown

Expected Output

Your console output should display three fields from the typed variables:

  • label= — the string value
  • level= — the number value
  • active= — the boolean value

Verify Your Answer

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.

Goal

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:

  1. Create the script:

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

  1. Multiply value by multiplier in scaleOpacity
  2. 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

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.

Goal

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:

  1. Create the script:

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

  1. Use type(label) and type(frames)
  2. Use type(weights[1]) and #weights
  3. 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

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.

Goal

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:

  1. Create the script:

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

  1. If raw is a string, convert it with tonumber
  2. If raw is already a number, use it directly
  3. Keep value as 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

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.

Goal

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:

  1. Create the script:

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

  1. Set label to Orbit
  2. Set progress to 0.75
  3. 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

Verify Your Answer

Checklist

  • State fields match their types
  • Output uses the updated values
  • Output matches the expected line

Knowledge Check

Q:What directive enables full type checking in Luau?
Q:What happens with 'local x = {}' in strict mode?
Q:What is type refinement?
Q:What's the difference between string? and string | nil?

Common Mistakes

Avoid These Errors
  1. 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 = {}
  2. Empty tables without types:

    -- WRONG
    local t = {}

    -- CORRECT
    local t: {string} = {}
  3. Wrong Rive constructors:

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

    -- CORRECT
    self.position = Vector.xy(100, 50)
  4. Not checking optionals:

    -- WRONG: email might be nil
    sendEmail(user.email)

    -- CORRECT: Check first
    if user.email then
    sendEmail(user.email)
    end
  5. 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 if checks
  • I use Rive's built-in types correctly (Vector, Color, etc.)

Next Steps