Appearance
Annotations and Inference
Written: 2026-10-14
Learning objectives
- You annotate locals, params, and returns with precise types.
- You read what the checker infers when you omit an annotation.
- You use primitives and Engine types as annotations.
- You shape tables and function values with type syntax.
- You name reusable shapes with aliases and share them with export type.
- You leave trivial locals unannotated.
Prerequisites
You annotate the edge, inference fills the middle
You tell the checker what crosses a boundary. The checker figures out the rest inside. Signatures describe the boundary and bodies stay readable.
A colon after a name sets its type. You can annotate a local, a param, and a return. The type checking guide shows this colon form. The syntax tour shows the same form for locals, params, returns, and aliases.
An annotation does not change runtime behavior. It only changes what the checker accepts. A wrong annotation rejects good runs. A missing annotation accepts bad runs in loose modes.
Annotate public functions and module boundaries. Let the checker infer short-lived locals. This puts checks where misuse happens and keeps bodies short.
Put this Script in ServerScriptService.
luau
--!strict
local coins: number = 40
local label: string = "coins"
local active: boolean = true
local empty: nil = nilThe checker accepts this block. You infer coins as number, label as string, active as boolean, and empty as nil. Each value matches its annotation.
Put this Script in ServerScriptService.
luau
--!strict
local function addCoins(current: number, gained: number): number
return current + gained
end
local total = addCoins(10, 5)The checker accepts this block. You infer total as number from the declared return. Both params and the return are annotated. The caller passes two numbers.
Put this Script in ServerScriptService.
luau
--!strict
local function addCoins(current: number, gained: number): number
return current + gained
end
local total = addCoins(10, "5")The checker rejects this block. It reports that a string cannot convert to number. You infer nothing usable for total on the bad line. The meaning matches the documented example where a string cannot convert into a number in type checking. Fix the call by passing a number.
Primitives and Engine types name what you already use
A primitive names a small value. An Engine type names a Roblox value. You use the same colon in both cases.
The core primitives are any, nil, boolean, number, string, and thread as listed in the syntax tour. Roblox adds Engine types for Instances, values, and enums. The type checking guide shows Part, BrickColor, and Enum.Material in exactly this role.
number covers all Roblox numbers. There is no separate integer or float annotation. thread names a coroutine, not a Task thread you schedule. Engine types name the value shape, not the asset or service that made it.
Pick the narrowest true type. You write number for counts and string for names. You write Part when you need a part, not a generic Instance.
Put this Script in Workspace.
luau
--!strict
local platform: Part = workspace:WaitForChild("Platform") :: Part
local paint: BrickColor = platform.BrickColor
local ground: Enum.Material = platform.MaterialThe checker accepts this block. You infer platform as Part, paint as BrickColor, and ground as Enum.Material. The cast on the first line narrows the looked-up Instance to Part. Each annotation matches the value you store.
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
export type ToolKind = "Sword" | "Bow"
local function kindName(kind: ToolKind): string
if kind == "Sword" then
return "blade"
end
return "string"
end
local picked = kindName("Bow")The checker accepts this block. You infer picked as string. ToolKind is a union of two string singletons. Only those two spellings pass. A call with "Hammer" errors because no union member matches.
Table shapes and function types describe structure
A table type lists the fields you promise. A function type lists the inputs and the output. The checker compares shapes, not names.
A list of numbers is {number}. A map from string to number is {[string]: number}. A record lists each field with a colon. A function maps inputs to output with ->. The type checking guide shows list, map, record, and function forms. The luau.org types intro shows the same structural view.
A table annotation seals the shape for later growth. Later chapters cover sealed growth in detail. A function type with the wrong arity rejects correct-looking calls. You must match both count and type.
Write small records with required fields. Write function types that match how you call them. Reuse those shapes instead of repeating them.
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
export type Stats = {
coins: number,
name: string,
}
local function bonus(stats: Stats): number
return stats.coins * 2
end
local got = bonus({ coins = 5, name = "Mara" })The checker accepts this block. You infer got as number. stats must carry both coins and name. The literal at the call matches both fields. A literal missing name errors because a required prop is absent.
Put this Script in ServerScriptService.
luau
--!strict
local scores: { number } = { 10, 20, 30 }
local byName: { [string]: number } = {
Mara = 12,
Quill = 7,
}
local apply: (number, number) -> number = function(a, b)
return a + b
end
local done = apply(2, 3)This block checks clean. You infer scores as a list of numbers, byName as a string-to-number map, apply as a two-number function, and done as number. Each annotation matches its value. A string inside scores would error. A boolean value inside byName would error.
Put this Script in ServerScriptService.
luau
--!strict
local function splitName(full: string): (string, string)
local cut = string.find(full, " ")
if cut == nil then
return full, ""
end
return string.sub(full, 1, cut - 1), string.sub(full, cut + 1)
end
local first, last = splitName("Ada Quill")This block checks clean. You infer first as string and last as string. Parentheses around the return list declare two results. Both return paths send two strings. A path sending one value or a number errors because arity and type must match.
Aliases name ideas, export shares them
Aliases give long or shared shapes a short name in one place.
type defines a local alias and export type shares one. A local alias lives in one file. An exported alias can be read through the requiring file's module value, as shown in type checking and the syntax tour. You write type Name = ... for local use. You write export type Name = ... for cross-module use.
A nonexported alias is invisible outside its file. An exported alias still needs a require on the other side. Renaming the module local does not rename the type access path.
Define item, result, and config shapes once in a shared ModuleScript. Require that module wherever the shape appears. Callers stay in sync when the shape changes.
Put this ModuleScript in ReplicatedStorage with the name GrantTypes.
luau
--!strict
export type GrantResult = {
ok: boolean,
grantedCount: number,
}
local function makeResult(count: number): GrantResult
return { ok = true, grantedCount = count }
end
local made: GrantResult = makeResult(3)This block checks clean. You infer made as GrantResult. The alias names a two-field record. The maker returns that exact shape. Consumers read made.ok as boolean and made.grantedCount as number.
Put this Script in ServerScriptService. It requires the GrantTypes ModuleScript from ReplicatedStorage.
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GrantTypes = require(ReplicatedStorage:WaitForChild("GrantTypes"))
local function grantCoins(player: Player, amount: number): GrantTypes.GrantResult
return { ok = true, grantedCount = amount }
endThis block checks clean. You infer grantCoins as (Player, number) -> GrantTypes.GrantResult. The return annotation points at the shared alias. The body returns both fields with matching types. A missing grantedCount would error.
You skip annotations where inference is obvious
Some locals explain themselves. Do not repeat what the next reader already sees.
Annotate params, returns, module values, and shared shapes. Omit annotations on locals whose right side already fixes the type. The luau.org types intro favors inference for locals and annotations at edges for the same reason.
An unannotated local follows its first assignment. A later assignment of a new type errors. That is often what you want. When you need a wider type, annotate up front with a union or optional.
Reviewers read intent at signatures. They skim bodies without annotation noise. Add annotations back when the checker or the next reader needs help.
Put this Script in ServerScriptService.
luau
--!strict
local retryCount = 0
local shopName = "Sky Shop"
local function price(count: number): number
return count * 25
endThis block checks clean. You infer retryCount as number, shopName as string, and price as (number) -> number. No local annotation is needed. The right sides already fix each type. You keep annotations on the function edge.
Common mistakes
Wrong: you annotate the container but store the wrong member type.
Put this Script in ServerScriptService.
luau
--!strict
local scores: { number } = { 10, "late", 30 }This block errors. The checker reports that a string member cannot live in a number list. You fix it by storing only numbers or by widening the annotation to a union when mixed values are truly intended.
luau
--!strict
local scores: { number } = { 10, 20, 30 }This fix checks clean. You infer scores as {number}.
Wrong: you forget a required record field.
Put this Script in ServerScriptService.
luau
--!strict
export type Stats = {
coins: number,
name: string,
}
local me: Stats = { coins = 5 }This block errors. The checker reports a missing required prop. You fix it by adding the field.
luau
--!strict
export type Stats = {
coins: number,
name: string,
}
local me: Stats = { coins = 5, name = "Mara" }This fix checks clean. You infer me as Stats.
Wrong: you annotate every trivial local.
Put this Script in ServerScriptService.
luau
--!strict
local coins: number = 0
local name: string = "Mara"
local rate: number = 2
local doubled: number = rate * 2This block checks clean but adds noise. Each right side already proves the type. You fix it by keeping annotations at function edges and letting locals infer.
luau
--!strict
local coins = 0
local name = "Mara"
local rate = 2
local doubled = rate * 2This fix checks clean. You infer coins as number, name as string, rate as number, and doubled as number.
Exercises
- Annotate a two-param number function and call it correctly and incorrectly.
- Store a
Part, itsBrickColor, and itsEnum.Materialin three annotated locals. - Define a record alias with two fields and construct one good and one bad value.
- Write a two-return string parser and bind both results.
- Export a result alias from one ModuleScript and use it in another script.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
local function total(price: number, count: number): number
return price * count
end
local good = total(4, 3)The checker accepts this block. You infer good as number. Both args match. Change the call to total(4, "3") and the checker rejects the string arg.
Solution 2
Put this Script in Workspace.
luau
--!strict
local base: Part = workspace:WaitForChild("Base") :: Part
local paint: BrickColor = base.BrickColor
local top: Enum.Material = base.MaterialThis block checks clean. You infer base as Part, paint as BrickColor, and top as Enum.Material. Each annotation names the Engine value you store. A Model in base would error.
Solution 3
Put this Script in ServerScriptService.
luau
--!strict
export type Pet = {
name: string,
hunger: number,
}
local fed: Pet = { name = "Pip", hunger = 1 }The checker accepts this block. You infer fed as Pet. Remove hunger and the checker reports a missing required prop.
Solution 4
Put this Script in ServerScriptService.
luau
--!strict
local function cut(full: string): (string, string)
local at = string.find(full, " ")
if at == nil then
return full, ""
end
return string.sub(full, 1, at - 1), string.sub(full, at + 1)
end
local a, b = cut("Sky Fox")This block checks clean. You infer a as string and b as string. Both paths return two strings. Return one value and arity checking rejects the path.
Solution 5
Put this ModuleScript in ReplicatedStorage with the name LootTypes.
luau
--!strict
export type Loot = {
id: string,
count: number,
}
local first: Loot = { id = "gem", count = 2 }This ModuleScript checks clean. You infer first as Loot. A second script can require this module and annotate locals as LootTypes.Loot.
Summary
- You annotate params, returns, and shared shapes.
- Primitives name small values. Engine types name Roblox values.
- Table and function syntax names structure.
- Local aliases shorten names. Exported aliases share contracts.
- You omit annotations where the right side already proves the type.
- Next: Modes.