Type Checking

Luwu type checks every file in strict mode by default and reports errors in your editor while you write. A file that isn't ready for that can relax checking with --!nonstrict at the top, or turn it off with --!nocheck.

Types don't change how your code runs, and values are still dynamically typed at runtime. That lets you start from untyped code and add annotations as you go, while inference works out most of the types you leave unwritten.

Catching mistakes

When you misspell a field, the checker flags the line with the typo and suggests the name you probably meant:

type Player = { name: string, level: number }

const function promote(player: Player)
    player.level += 1
end

promote({ name = "Ada", levl = 3 })
Typo: Did you mean 'level' instead of 'levl'?
TypeError: The given table is missing a property for 'Player':
  • 'level: number'

Without the checker, this program would fail later when something reads the missing level.

Inference

Most code needs few annotations. Luwu infers the types of locals, return values and tables for you:

const function average(numbers: { number })
    local total = 0
    for _, n in numbers do
        total += n
    end
    return total / #numbers
end

const result = average({ 4, 8, 15 })
print(result)

Here only the parameter is annotated. total and the return value are inferred as numbers, and result is a number too.

Gradual typing

Untyped code still runs and still type checks. Its parameters accept anything until you annotate them:

const function label(name, count)
    return `{name} x{count}`
end

print(label("Potion", 3))

Adding annotations later tightens the checks without changing how the code runs:

const function label(name: string, count: number): string
    return `{name} x{count}`
end

print(label("Potion", 3))

To opt a file out of strict checking, put --!nonstrict or --!nocheck on its first line.

Annotations and the compiler

Type annotations can also make your code faster. Luau's compiler already reads them as optimization hints, and Luwu takes that further: in a file marked --!trust, the compiler trusts your annotations and uses them to inline method calls. All it asks is that your code is strictly typed, and if you're switching to Luwu you're probably writing it that way already. If an annotation ever doesn't match the value it describes, you get a runtime error instead of quietly wrong behavior.

Error messages

We purposely limit jargon in type errors. We omit terms like subtyping, variance or invariance and instead rephrase with code-related examples. And when jargon is needed, we like to attach a "Help" explanation to describe what the term is and why we've flagged your code because of it.

Nyla, the Luwu cat, lying on her back with her paws up, saying "nyeh..."
Nyla finding a recursive CYCLE in her fancatstic library. Art by Cat! (@meowzer_meow on Discord)

We also try to talk about your code using the names you gave it. Instead of saying "this" or printing out an entire type, an error points at the variable or the property you used, like stool.inputs[i].count, and calls a type by its alias when it has one.

Where possible, Luwu adds blue 'Info' notes (spans) to error messages to help you track down the root cause of a property mismatch through multiple variables if needed. This means you don't have to manually trace error string paths back to where they came from.

The examples below were checked with upstream Luau 0.742's luau-analyze and with luwu-analyze. The output is unedited apart from the file name.

Typos

Luwu looks for typos wherever a name or a string is checked against a known set: locals, globals, properties, methods, library functions, type names, string literals and tagged union tags. Luau reports most of these as something else, like a missing property, or not at all.

Misspelled locals

A misspelled local:

--!strict
local player_name = "Ada"
print(player_nmae)
main.luau(3,7): TypeError: Typo 'player_nmae': Did you mean 'player_name'?
Misspelled globals

Built-in globals count too. Luau suggests assigning to the misspelled name:

--!strict
pirnt("hello")
main.luau(2,1): TypeError: Typo 'pirnt': Did you mean 'print'?
Misspelled properties

Luau reports the property the table is missing. Luwu also names the misspelled key:

--!strict
type Player = { name: string, level: number }

local ada: Player = { name = "Ada", levl = 3 }
main.luau(4,37): Typo: Did you mean 'level' instead of 'levl'?
main.luau(4,21): TypeError: 'ada' is missing a property for 'Player':
  • 'level: number'
Misspelled optional properties

When the property you meant is optional, nothing is missing. Luau 0.742 reports nothing at all and the subtitle is silently lost. Luwu still catches it:

--!strict
type Options = { title: string, subtitle: string?, width: number? }

local function open_window(options: Options) end

open_window({ title = "Inventory", subtilte = "Items" })
main.luau(6,36): Typo: Did you mean 'subtitle' instead of 'subtilte'?
Misspelled property reads

Luau says the key isn't in the table. Luwu names the property you meant by its path:

--!strict
type Player = { name: string, level: number }

local function greet(player: Player): string
    return `Hello, {player.nmae}`
end
main.luau(5,21): Typo: Did you mean 'player.name' instead of 'player.nmae'?
Misspelled property writes

Luau says the property can't be added to the table. Luwu says which existing property you meant:

--!strict
type Settings = { volume: number, muted: boolean? }

local function mute(settings: Settings)
    settings.mutd = true
end
main.luau(5,5): Typo: Did you mean 'settings.muted' instead of 'settings.mutd'?
Misspelled methods

Methods on built-in types, like strings, count too:

--!strict
local text = "  Ada  "
print(text:uper())
main.luau(3,7): Typo: Did you mean 'text:upper' instead of 'text:uper'?
Misspelled library functions

Luau shows the library's internal type, typeof(table). Luwu suggests the function you meant:

--!strict
local words = { "b", "a" }
table.srot(words)
main.luau(3,1): Typo: Did you mean 'table.sort' instead of 'table.srot'?
Misspelled type names

Type names count too:

--!strict
type Player = { name: string }

local function greet(player: Plyer) end
main.luau(4,30): TypeError: Typo 'Plyer': Did you mean 'Player'?
String literal typos

Luau lists the values it allows. Luwu spots the one you meant:

--!strict
type Team = "Red" | "Blue" | "Green"

local function join(team: Team) end

join("Bleu")
main.luau(6,6): Typo: Did you mean '"Blue"' instead of '"Bleu"'?
Misspelled properties in tagged unions

Luau says the value doesn't match the union. Luwu uses the kind tag to pick the member you meant and then finds the misspelled property:

--!strict
type Event =
    | { kind: "join", player: string }
    | { kind: "leave", player: string, reason: string }

local function handle(event: Event) end

local leaving = { kind = "leave" :: "leave", player = "Ada", reson = "afk" }
handle(leaving)
main.luau(9,8): Typo: Did you mean 'leaving.reason' instead of 'leaving.reson'?
Misspelled tags

Because a misspelled tag no longer matches any variant, Luau reports a mismatch against the whole union. Luwu spots the typo in the tag itself:

--!strict
type Event =
    | { kind: "join", player: string }
    | { kind: "leave", player: string, reason: string }

local function handle(event: Event) end

handle({ kind = "laeve", player = "Ada", reason = "afk" })
main.luau(8,17): Typo: Did you mean '"leave"' instead of '"laeve"'?

Unknown names

Luau reports every name it can't find as an unknown global and suggests assigning to it. Luwu suggests defining a local instead because code rarely means to create a global. When there's a better explanation, Luwu gives that: a local used too early, one out of scope, or a module that was never required.

Undefined variables

A name used outside the function that defines it:

--!strict
local function load_save()
    local save_data = { level = 3 }
    return true
end

load_save()
print(save_data.level)
main.luau(8,7): TypeError: Unknown variable 'save_data', define it with 'local save_data = value'
Use before definition

A local used above its definition:

--!strict
print(greeting)
local greeting = "hello"
main.luau(2,7): TypeError: Can't use 'greeting' defined on line 3 before it exists
Inner scopes

A local defined inside an if, then used after it:

--!strict
local function open_chest(locked: boolean): number
    if not locked then
        local reward = 10
    end
    return reward
end
main.luau(6,12): TypeError: 'reward' is not accessible here because it was defined in an inner scope on line 4
main.luau(4,15): Note: Consider moving this binding above this 'if' statement to use it on line 6
Missing library functions

Luau shows the library's internal type, typeof(string). Luwu names the library:

--!strict
local name = "ada lovelace"
print(string.title(name))
main.luau(3,7): TypeError: The 'string' library has no member 'title'
Unknown qualified types

Luwu says which half of math_utils.Point is wrong:

--!strict
local math_utils = {}
type Point = math_utils.Point
main.luau(3,14): TypeError: Unknown type 'math_utils.Point': 'math_utils' isn't a required module

Tables

Luwu names the variable, writes the path to each bad property the way you'd index it, and lists every problem at once.

Tables with several mistakes

Luau names the two types without saying why they don't match. Luwu lists every property that doesn't fit by its path:

--!strict
type Stack = { id: string, count: number }
type Recipe = {
    output: Stack,
    inputs: { Stack },
    station: "bench" | "forge" | "loom",
}

local recipes: { [string]: Recipe } = {}

local stool = {
    output = { id = "stool", count = 1 },
    inputs = { { id = "plank", count = "3" } },
    station = "workbench" :: string,
}
recipes.stool = stool
main.luau(16,17): TypeError: Expected 'stool' to be
    'Recipe?'
but was given
    '{ inputs: {{ count: string, … }}, station: string, … }'

Some properties are incompatible:
  • stool.inputs[i].count: expected 'number', got 'string'
  • stool.station: expected '"bench" | "forge" | "loom"', got 'string'
main.luau(2,28): Note: Expected 'count: number' because of this
main.luau(13,32): Note: 'count' is 'string' here, but should be 'number'
main.luau(6,5): Note: Expected 'station: "bench" | "forge" | "loom"' because of this
main.luau(14,5): Note: 'station' is 'string' here, but should be '"bench" | "forge" | "loom"'

Help (paths above):
  • '[i]' is any element of an array
Nested maps and arrays

Luau describes the path through each indexer step by step. Luwu writes it the way you'd index it:

--!strict
type Stack = { id: string, count: number }
type Inventory = { byCategory: { [string]: { [string]: { Stack } } } }

local function load(raw: { [string]: { [string]: { { id: string, count: string } } } }): Inventory
    return { byCategory = raw }
end
main.luau(6,27): TypeError: Expected 'raw' to be
    '{ [string]: { [string]: {Stack} } }'
but was given
    '{ [string]: { [string]: {{ count: string, … }} } }'

Some properties are incompatible:
  • raw[string][string][i].count: expected 'number', got 'string'
main.luau(2,28): Note: Expected 'count: number' because of this
main.luau(5,66): Note: 'count' is 'string' here, but should be 'number'
Missing properties

A color nested two levels deep in a theme is missing its blue channel. Luau describes the table by its expanded type. Luwu names it by the path from the variable, dark.colors.text:

--!strict
type Color = { r: number, g: number, b: number }
type Theme = { name: string, colors: { background: Color, text: Color } }

local dark: Theme = {
    name = "dark",
    colors = {
        background = { r = 0, g = 0, b = 0 },
        text = { r = 1, g = 1 },
    },
}
main.luau(9,16): TypeError: 'dark.colors.text' is missing a property for 'Color':
  • 'b: number'
Missing properties through other variables

The color is built in white, stored in colors, then passed in a call. Luau names white but not what's wrong with it. Luwu follows the value back and names the missing property by its path, colors.background.b:

--!strict
type Color = { r: number, g: number, b: number }
type Theme = { name: string, colors: { background: Color, text: Color } }

local function apply(theme: Theme) end

local white = { r = 1, g = 1 }
local colors = { background = white, text = { r = 0, g = 0, b = 0 } }
apply({ name = "light", colors = colors })
main.luau(9,34): TypeError: Expected 'colors' to be
    '{ background: Color, text: Color }'
but was given
    '{ background: { … }, … }'

Some properties are incompatible:
  • missing property 'colors.background.b: number'
main.luau(2,38): Note: Expected 'b: number' because of this
main.luau(7,15): Note: 'colors.background' is missing 'b: number'
Records where a map is expected

Luau names the two types. Luwu says what kind of mismatch it is:

--!strict
type Scores = { [string]: number }

local function total(scores: Scores): number
    local sum = 0
    for _, score in scores do
        sum += score
    end
    return sum
end

type Round = { red: number, blue: number }
local round: Round = { red = 3, blue = 5 }
total(round)
main.luau(14,7): TypeError: Expected 'round' to be
    'Scores: { [string]: number }'
but was given
    'Round: { blue: number, red: number }'

'round' is a record where map 'Scores' is expected.
Unrelated tables

Luau names the two aliases. Luwu spells out both shapes next to their names. Because they share no properties, Luwu says the types are unrelated instead of listing every difference: you most likely passed the wrong value.

--!strict
type Vector2 = { x: number, y: number }
type Settings = { volume: number, muted: boolean }

local function move(by: Vector2) end

local settings: Settings = { volume = 1, muted = false }
move(settings)
main.luau(8,6): TypeError: Expected 'settings' to be
    'Vector2: { x: number, y: number }'
but was given
    'Settings: { muted: boolean, volume: number }'

'Settings' is unrelated to 'Vector2'.
Recursive types

A JSON value passed where a config is expected. Luau lists every way the recursive Json type fails to match down to the string metatable. Luwu says the two types are unrelated:

--!strict
type Json = string | number | boolean | { Json } | { [string]: Json }
type ServerConfig = { port: number, host: string, maps: { { name: string, weight: number } } }

local function start(config: ServerConfig) end

local raw: Json = {}
start(raw)
main.luau(8,7): TypeError: Expected 'raw' to be 'ServerConfig', but was given 'Json'

'Json' is unrelated to 'ServerConfig'.
Arrays that could be written to

Luau says the element type should be exactly nil. Luwu explains why the array is rejected and how to fix it:

--!strict
type Stack = { id: string, count: number }

local function compact(slots: { Stack? }): { Stack }
    local out = {}
    for _, slot in slots do
        if slot then
            table.insert(out, slot)
        end
    end
    return out
end

local packed: { Stack } = {}
compact(packed)
main.luau(15,9): TypeError: This array with non-optional elements cannot be passed where optional elements are allowed; doing so would allow 'nil' to be written into it.

Help (read/write mismatch):
  • Annotate the expected indexer as 'read' if nothing writes to it
  • Cast this array to '{Stack?}' if you know 'nil' will not be written to it
main.luau(4,33): TypeError: Help (read/write mismatch):
  • Consider marking this as 'read' if the function 'compact' only reads from the array

Unions and tagged unions

Luwu reads a tagged union's tag to find the variant you meant and then explains what doesn't fit that variant.

Wrong property types in tagged unions

A tagged union, like an enum with data, uses one property (here kind) to say which variant a table is. Luau prints the whole union and the given table. Luwu reads the tag, compares the table with the matching variant and points at the wrong property:

--!strict
type Shape =
    | { kind: "circle", radius: number }
    | { kind: "rect", width: number, height: number }

local function area(shape: Shape): number
    return 0
end

area({ kind = "rect", width = 4, height = "3" })
main.luau(10,6): TypeError: Expected this to be
    type Shape = … | { height: number, kind: "rect", width: number }
but was given
    '{ height: string, kind: "rect", … }'

'given.kind' is '"rect"', so 'given' was compared with Shape#2:
  • given.height: expected 'number', got 'string'
main.luau(4,38): Note: Expected 'height: number' because of this
Empty tables for tagged unions

Starting from an empty table, Luau prints the whole union. Luwu lists the tag every variant needs and then what each variant needs on top of it:

--!strict
type Message =
    | { kind: "text", body: string }
    | { kind: "image", url: string, width: number, height: number }
    | { kind: "poll", question: string, options: { string } }

local function send(message: Message) end

send({})
main.luau(9,6): TypeError: The given table is missing a property every member of 'Message' has:
  • 'kind: "text" | "image" | "poll"'
Some members need more:
  • 'kind = "text"' also needs 'body: string'
  • 'kind = "image"' also needs 'url: string', 'width: number' and 'height: number'
  • 'kind = "poll"' also needs 'question: string' and 'options: {string}'
Missing properties in a variant

Luau finds the variant too and prints both table types in full. Luwu names the exact item in the array and the property it needs:

--!strict
type Action =
    | { type: "move", x: number, y: number }
    | { type: "attack", target: string, damage: number }
    | { type: "wait" }

local queue: { Action } = {
    { type = "move", x = 3, y = 4 },
    { type = "attack", target = "Slime" },
    { type = "wait" },
}
main.luau(9,5): TypeError: 'queue[2]' is missing a property for 'Action':
  • 'damage: number'
Unions

Luau lists the members that don't fit. Luwu shows the member closest to fitting and what it's missing:

--!strict
type Stack = { id: string, count: number }
type Drop = Stack | { id: string, weight: number } | { gold: number }

local function describe(item: Stack): string
    return `{item.count}x {item.id}`
end

local last_drop: Drop = { gold = 5 }
describe(last_drop)
main.luau(10,10): TypeError: Expected 'last_drop' to be
    'Stack'
but was given
    type Drop = … | { id: string, weight: number } | …

Not every member of 'Drop' is compatible with 'Stack':
last_drop#2 = { id: string, weight: number } is almost what we expected, but:
  • missing property 'last_drop.count: number'
main.luau(2,28): Note: Expected 'count: number' because of this
main.luau(3,21): Note: 'last_drop' is missing 'count: number'

Help (paths above):
  • '#2' is the 2nd member of a union, counting in the order printed

Functions

Luwu prints function types with their parameter names and explains each parameter and return value that doesn't fit.

Swapped return values

Luau reports three separate problems with the 1st and 2nd values. Luwu sees that they're swapped:

--!strict
type Validate = (input: string) -> (boolean, string?)

local validate: Validate = function(input: string): (string?, boolean)
    return nil, #input > 0
end
main.luau(4,28): TypeError: Expected 'validate' to be
    '(input: string) -> (boolean, string?)'
but was given
    '(input: string) -> (string?, boolean)'

Help (return order):
  • The 1st and 2nd return values look like they're in the wrong order
Callbacks that accept too little

Luau reports that a type isn't a supertype of nil. Luwu explains who passes the nil:

--!strict
type Stack = { id: string, count: number }
type Filter = (slot: Stack?, index: number) -> boolean

local is_plank: Filter = function(slot: Stack, index: number): boolean
    return slot.id == "plank"
end
main.luau(5,26): TypeError: Expected 'is_plank' to be
    '(slot: Stack?, index: number) -> boolean'
but was given
    '(slot: Stack, index: number) -> boolean'

This function can be passed a value it doesn't accept:
  • slot: expected 'Stack?', got 'Stack'

Help (narrowed parameter):
  • 'slot' can't be narrowed from 'Stack?' to 'Stack'
  • Code calling this function as the expected type is allowed to pass 'nil' for 'slot'
  • This parameter must accept 'Stack?' to match the caller's expected signature
An array instead of a single value
--!strict
type Stack = { id: string, count: number }
type Chest = {
    take: (id: string, count: number?) -> Stack?,
}

local chest: Chest = {
    take = function(id: string, count: number?): { Stack }
        return {}
    end,
}
main.luau(8,12): TypeError: Expected 'chest.take' to be
    '(id: string, count: number?) -> Stack?'
but was given
    '(id: string, count: number?) -> {Stack}'

This function should return:
  • a single 'Stack?', not an array of them
Extra parameters

Luau compares the parameter lists as a whole. Luwu names the extra parameter:

--!strict
type Button = { label: string, on_click: (x: number, y: number) -> () }

local play: Button = {
    label = "Play",
    on_click = function(x: number, y: number, double_click: boolean) end,
}
main.luau(6,16): TypeError: 'play.on_click' should only have 2 parameters, but was passed a function with an extra parameter 'double_click: boolean'.
Swapped parameters

Luau reports both parameters separately. Luwu notices they're in the wrong order:

--!strict
type Damage = (target: string, amount: number) -> ()

local hit: Damage = function(amount: number, target: string) end
main.luau(4,21): TypeError: Expected 'hit' to be
    '(target: string, amount: number) -> ()'
but was given
    '(amount: number, target: string) -> ()'

Help (parameter order):
  • The parameters 'amount' and 'target' look like they're in the wrong order
Functions inside tables

Luau describes its path through the plugin's hooks and adds a second reason about type packs. Luwu names the function and the parameter that doesn't fit:

--!strict
type Plugin = {
    name: string,
    hooks: {
        init: (config: { [string]: string }) -> boolean,
        shutdown: () -> (),
    },
}

local legacy = {
    name = "minimap",
    hooks = {
        init = function(config: { [string]: number }): boolean
            return true
        end,
        shutdown = function() end,
    },
}

local plugins: { Plugin } = { legacy }
main.luau(20,31): TypeError: Expected 'legacy' to be
    'Plugin'
but was given
    '{ hooks: { init: (config: { [string]: number }) -> boolean, … }, … }'
where
    type Init = (config: { [string]: number }) -> boolean

Some properties are incompatible:
  • legacy.hooks.init = Init
    In 'legacy.hooks.init', config[string]: expected 'string', got 'number'
Overloads

Luau doesn't name the function and prints every overload on one line. Luwu names the function, shows the arguments you passed, and lists each overload with its parameter names:

--!strict
type Bus = {
    on: ((event: "chat", handler: (message: string) -> ()) -> ())
        & ((event: "join", handler: (player: string, team: number) -> ()) -> ()),
}

local function listen(bus: Bus)
    bus.on("chat", function(message: number) end)
end
main.luau(8,5): TypeError: None of the overloads of 'bus.on' accept '("chat", (message: number) -> ())':
  • #1 '(event: "chat", handler: (message: string) -> ()) -> ()'
  • #2 '(event: "join", handler: (player: string, team: number) -> ()) -> ()'

Metatable classes

Metatables are how Luau code builds objects. Luwu prints their large, self-referencing types by name instead of expanding them.

Metatable inheritance

Classes built from metatables have recursive types. Luau prints them in full with *CYCLE* markers where the type refers back to itself. Luwu prints the types by name and lists the two problems with the overriding method:

--!strict
type EnemyImpl = {
    __index: EnemyImpl,
    new: (name: string, health: number) -> Enemy,
    take_damage: (self: Enemy, amount: number) -> boolean,
}
export type Enemy = typeof(setmetatable({} :: { name: string, health: number }, {} :: EnemyImpl))

type BossImpl = EnemyImpl & {
    phase: (self: Boss) -> number,
}
export type Boss = typeof(setmetatable({} :: { name: string, health: number, enraged: boolean }, {} :: BossImpl))

local Enemy = {} :: EnemyImpl
local Boss = setmetatable({}, { __index = Enemy }) :: BossImpl

Boss.take_damage = function(self: Boss, amount: number): number
    self.health -= amount
    return self.health
end
main.luau(17,20): TypeError: Expected 'Boss.take_damage' to be
    '(self: Enemy, amount: number) -> boolean'
but was given
    '(self: Boss, amount: number) -> number'

This function can be passed a value it doesn't accept:
  • missing property 'self.enraged: boolean'
It should return:
  • 'boolean', not 'number'
main.luau(12,78): Note: Expected 'enraged: boolean' because of this
main.luau(7,47): Note: 'self' is missing 'enraged: boolean'
Generic metatable classes

Promise.all can't give an array of strings here because one of the promises resolves to a number. Luau 0.742 accepts the code without an error. Luwu finds the generic parameter no type can satisfy:

--!strict
type PromiseImpl = {
    __index: PromiseImpl,
    resolve: <T>(value: T) -> Promise<T>,
    all: <T>(promises: { Promise<T> }) -> Promise<{ T }>,
    and_then: <T, U>(self: Promise<T>, on_resolve: (T) -> U) -> Promise<U>,
}

export type Promise<T> = typeof(setmetatable(
    {} :: { status: "Started" | "Resolved" | "Rejected", value: T? },
    {} :: PromiseImpl
))

local Promise = {} :: PromiseImpl

local function load_names(): Promise<{ string }>
    return Promise.all({
        Promise.resolve("Ada"),
        Promise.resolve(42),
    })
end
main.luau(17,5): TypeError: Expected this to be
    'Promise'
but was given
    '{ @metatable PromiseImpl, { value: {string & number & (((string & number) | (string & nil))?)}?, … } }'
where
    type Value = {string & number & (((string & number) | (string & nil))?)}

Some properties are incompatible:
  • given.value: expected '{string}?', got 'Value?'
main.luau(17,12): TypeError: This call has no valid type for 'T' that fits every place 'T' is used.
  'T' is given:   string | number
  'T' is used as: string & number

Help (impossible generic parameter instantiation):
  - A call picks one type for 'T' and uses it everywhere 'T' appears in the function's signature
  - What 'T' is given has to fit everywhere 'T' is used
  - To fix this, pass a value that fits 'string & number', or make the code that uses 'T' accept 'string | number'

Things to know