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.

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'?--!strict
local player_name = "Ada"
print(player_nmae)main.luau(3,7): TypeError: Unknown global 'player_nmae'; consider assigning to it firstMisspelled 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'?--!strict
pirnt("hello")main.luau(2,1): TypeError: Unknown global 'pirnt'; consider assigning to it firstMisspelled 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'--!strict
type Player = { name: string, level: number }
local ada: Player = { name = "Ada", levl = 3 }main.luau(4,21): TypeError: Table type '{ levl: number, name: string }' not compatible with type 'Player' because the former is missing field 'level'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'?--!strict
type Options = { title: string, subtitle: string?, width: number? }
local function open_window(options: Options) end
open_window({ title = "Inventory", subtilte = "Items" })No errors.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}`
endmain.luau(5,21): Typo: Did you mean 'player.name' instead of 'player.nmae'?--!strict
type Player = { name: string, level: number }
local function greet(player: Player): string
return `Hello, {player.nmae}`
endmain.luau(5,21): TypeError: Key 'nmae' not found in table 'Player'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
endmain.luau(5,5): Typo: Did you mean 'settings.muted' instead of 'settings.mutd'?--!strict
type Settings = { volume: number, muted: boolean? }
local function mute(settings: Settings)
settings.mutd = true
endmain.luau(5,5): TypeError: Cannot add property 'mutd' to table 'Settings'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'?--!strict
local text = " Ada "
print(text:uper())main.luau(3,7): TypeError: Type 'string' does not have key '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'?--!strict
local words = { "b", "a" }
table.srot(words)main.luau(3,1): TypeError: Key 'srot' not found in table 'typeof(table)'Misspelled type names
Type names count too:
--!strict
type Player = { name: string }
local function greet(player: Plyer) endmain.luau(4,30): TypeError: Typo 'Plyer': Did you mean 'Player'?--!strict
type Player = { name: string }
local function greet(player: Plyer) endmain.luau(4,30): TypeError: Unknown type 'Plyer'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"'?--!strict
type Team = "Red" | "Blue" | "Green"
local function join(team: Team) end
join("Bleu")main.luau(6,6): TypeError: Expected this to be '"Blue" | "Green" | "Red"', but got '"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'?--!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): TypeError: Expected this to be '{ kind: "join", player: string } | { kind: "leave", player: string, reason: string }', but got 'leaving'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"'?--!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,8): TypeError: Expected this to be
'{ kind: "join", player: string } | { kind: "leave", player: string, reason: string }'
but got
'{ kind: string, player: string, reason: string }'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'--!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 global 'save_data'; consider assigning to it firstUse 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--!strict
print(greeting)
local greeting = "hello"main.luau(2,7): TypeError: Unknown global 'greeting'; consider assigning to it firstInner 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
endmain.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--!strict
local function open_chest(locked: boolean): number
if not locked then
local reward = 10
end
return reward
endmain.luau(6,12): TypeError: Unknown global 'reward'; consider assigning to it firstMissing 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'--!strict
local name = "ada lovelace"
print(string.title(name))main.luau(3,7): TypeError: Key 'title' not found in table 'typeof(string)'Unknown qualified types
Luwu says which half of math_utils.Point is wrong:
--!strict
local math_utils = {}
type Point = math_utils.Pointmain.luau(3,14): TypeError: Unknown type 'math_utils.Point': 'math_utils' isn't a required module--!strict
local math_utils = {}
type Point = math_utils.Pointmain.luau(3,14): TypeError: Unknown type 'math_utils.Point'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 = stoolmain.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--!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 = stoolmain.luau(16,17): TypeError: Expected this to be 'Recipe?', but got 'stool'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 }
endmain.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'--!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 }
endmain.luau(6,27): TypeError: Expected this to be
'{ [string]: { [string]: {Stack} } }'
but got
'{ [string]: { [string]: {{ count: string, id: string }} } }';
Expected property `count` in the indexer result of the indexer result of the indexer result to be exactly `number`, but got `string`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'--!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: Table type '{ g: number, r: number }' not compatible with type 'Color' because the former is missing field 'b'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'--!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 this to be '{ background: Color, text: Color }', but got 'colors';
Expected property `background` to be exactly `Color`, but got `white`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.--!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 this to be 'Scores', but got 'Round'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'.--!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 this to be 'Vector2', but got 'Settings'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'.--!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 this to be
'ServerConfig'
but got
't1 where t1 = boolean | number | string | { [string]: t1 } | {t1}';
this is because
* `boolean` is not a subtype of `ServerConfig`
* `number` is not a subtype of `ServerConfig`
* `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` is not a subtype of `ServerConfig`
* property `__index` in the metatable has type `typeof(string)`, which is not a subtype of `ServerConfig`
* property `__index` in the metatable of the indexer result has type `typeof(string)` and property `maps` has type `{{ name: string, weight: number }}`, and `typeof(string)` is not exactly `{{ name: string, weight: number }}`
* property `__index` in the metatable of the indexer result of the indexer result has type `typeof(string)` and the indexer result of property `maps` is `{ name: string, weight: number }`, and `typeof(string)` is not exactly `{ name: string, weight: number }`
* the indexer key type of the indexer result is `string` and the indexer key type of property `maps` is `number`, and `string` is not exactly `number`
* the indexer result is `boolean` and property `host` has type `string`, and `boolean` is not exactly `string`
* the indexer result is `boolean` and property `maps` has type `{{ name: string, weight: number }}`, and `boolean` is not exactly `{{ name: string, weight: number }}`
* the indexer result is `boolean` and property `port` has type `number`, and `boolean` is not exactly `number`
* the indexer result is `number` and property `host` has type `string`, and `number` is not exactly `string`
* the indexer result is `number` and property `maps` has type `{{ name: string, weight: number }}`, and `number` is not exactly `{{ name: string, weight: number }}`
* the indexer result is `string` and property `port` has type `number`, and `string` is not exactly `number`
* the indexer result is `t1 where t1 = boolean | number | string | { [string]: t1 } | {t1}` and property `maps` has type `{{ name: string, weight: number }}`, and `t1 where t1 = boolean | number | string | { [string]: t1 } | {t1}` is not exactly `{{ name: string, weight: number }}`
* the indexer result is `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` and property `host` has type `string`, and `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` is not exactly `string`
* the indexer result is `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` and property `port` has type `number`, and `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` is not exactly `number`
* the indexer result is `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` and property `host` has type `string`, and `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` is not exactly `string`
* the indexer result is `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` and property `port` has type `number`, and `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` is not exactly `number`
* the indexer result of the indexer result is `boolean` and the indexer result of property `maps` is `{ name: string, weight: number }`, and `boolean` is not exactly `{ name: string, weight: number }`
* the indexer result of the indexer result is `number` and the indexer result of property `maps` is `{ name: string, weight: number }`, and `number` is not exactly `{ name: string, weight: number }`
* the indexer result of the indexer result is `t1 where t1 = boolean | number | string | { [string]: t1 } | {t1}` and the indexer result of property `maps` is `{ name: string, weight: number }`, and `t1 where t1 = boolean | number | string | { [string]: t1 } | {t1}` is not exactly `{ name: string, weight: number }`
* the indexer result of the indexer result is `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` and the indexer result of property `maps` is `{ name: string, weight: number }`, and `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` is not exactly `{ name: string, weight: number }`
* the indexer result of the indexer result of the indexer result is `boolean` and property `name` in the indexer result of property `maps` has type `string`, and `boolean` is not exactly `string`
* the indexer result of the indexer result of the indexer result is `boolean` and property `weight` in the indexer result of property `maps` has type `number`, and `boolean` is not exactly `number`
* the indexer result of the indexer result of the indexer result is `number` and property `name` in the indexer result of property `maps` has type `string`, and `number` is not exactly `string`
* the indexer result of the indexer result of the indexer result is `string` and property `weight` in the indexer result of property `maps` has type `number`, and `string` is not exactly `number`
* the indexer result of the indexer result of the indexer result is `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` and property `name` in the indexer result of property `maps` has type `string`, and `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` is not exactly `string`
* the indexer result of the indexer result of the indexer result is `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` and property `weight` in the indexer result of property `maps` has type `number`, and `t1 where t1 = { [string]: t2 } ; t2 = boolean | number | string | t1 | {t2}` is not exactly `number`
* the indexer result of the indexer result of the indexer result is `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` and property `name` in the indexer result of property `maps` has type `string`, and `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` is not exactly `string`
* the indexer result of the indexer result of the indexer result is `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` and property `weight` in the indexer result of property `maps` has type `number`, and `t1 where t1 = {t2} ; t2 = boolean | number | string | t1 | { [string]: t2 }` is not exactly `number`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--!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: Expected this to be '{Stack?}', but got '{Stack}';
Expected the indexer result to be exactly `nil`, but got `Stack`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--!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
'{ height: number, kind: "rect", width: number } | { kind: "circle", radius: number }'
but got
'{ height: string, kind: "rect", width: number }'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}'--!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: Expected this to be '{ body: string, kind: "text" } | { height: number, kind: "image", url: string, width: number } | { kind: "poll", options: {string}, question: string }', but got '{ }'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'--!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: Table type '{ target: string, type: "attack" }' not compatible with type '{ damage: number, target: string, type: "attack" }' because the former is missing field 'damage'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--!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 this to be 'Stack', but got 'Stack | { gold: number } | { id: string, weight: number }';
this is because
* `{ gold: number }` is not a subtype of `Stack`
* `{ id: string, weight: number }` is not a subtype of `Stack`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
endmain.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--!strict
type Validate = (input: string) -> (boolean, string?)
local validate: Validate = function(input: string): (string?, boolean)
return nil, #input > 0
endmain.luau(4,28): TypeError: Expected this to be
'(string) -> (boolean, string?)'
but got
'(string) -> (string?, boolean)';
this is because
* Expected the 1st return value to be `boolean`, but got `nil`
* Expected the 1st return value to be `boolean`, but got `string`
* Expected the 2nd return value to be `string?`, but got `boolean`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"
endmain.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--!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"
endmain.luau(5,26): TypeError: Expected this to be
'(Stack?, number) -> boolean'
but got
'(Stack, number) -> boolean';
Expected the 1st parameter to be a supertype of `nil`, but got `Stack`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--!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 this to be
'(string, number?) -> Stack?'
but got
'(string, number?) -> {Stack}';
Expected the return type to be `Stack?`, but got `{Stack}`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'.--!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: Expected this to be
'(number, number) -> ()'
but got
'(number, number, boolean) -> ()';
Expected the parameter types to be a supertype of `number, number`, but got `number, number, 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) endmain.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--!strict
type Damage = (target: string, amount: number) -> ()
local hit: Damage = function(amount: number, target: string) endmain.luau(4,21): TypeError: Expected this to be
'(string, number) -> ()'
but got
'(number, string) -> ()';
this is because
* Expected the 1st parameter to be a supertype of `string`, but got `number`
* Expected the 2nd parameter to be a supertype of `number`, but got `string`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'--!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 this to be 'Plugin', but got 'legacy';
this is because
* Expected the indexer result of the 1st parameter of property `hooks.init` to be exactly `string`, but got `number`
* the parameter type pack tail of property `hooks.init` is `...any` and the parameter types of property `hooks.init` are `{ [string]: string }`, and `...any` is not exactly `{ [string]: string }`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)
endmain.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) -> ()) -> ()'--!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)
endmain.luau(8,5): TypeError: None of the overloads for function that accept 2 arguments are compatible.
main.luau(8,5): TypeError: Available overloads: ("chat", (string) -> ()) -> (); and ("join", (string, 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
endmain.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'--!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
endmain.luau(17,20): TypeError: Expected this to be
'(Enemy, number) -> boolean'
but got
'(t1, number) -> number where t1 = setmetatable<{ enraged: boolean, health: number, name: string }, { __index: *CYCLE*, new: (string, number) -> setmetatable<{ health: number, name: string }, *CYCLE*>, take_damage: (setmetatable<{ health: number, name: string }, *CYCLE*>, number) -> boolean } & { phase: (t1) -> number }>';
this is because
* Expected the metatable of the 1st parameter to be a supertype of `EnemyImpl`, but got `t1 where t1 = { phase: (setmetatable<{ enraged: boolean, health: number, name: string }, t1 & { __index: *CYCLE*, new: (string, number) -> setmetatable<{ health: number, name: string }, *CYCLE*>, take_damage: (setmetatable<{ health: number, name: string }, *CYCLE*>, number) -> boolean }>) -> number }`
* Expected the return type to be `boolean`, but got `number`
* Expected the table portion of the 1st parameter to be a supertype of `{ health: number, name: string }`, but got `{ enraged: boolean, health: number, name: string }`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),
})
endmain.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'--!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),
})
endNo errors.Things to know
- Classes and traits are nominal types. Two classes with the same fields are still different types.
- Validators check string literals, like colors or sizes, with your own error messages.
- Type annotations describe values. They're separate from what
type(x)returns.