Appearance
Subtyping and Compatibility
Written: 2026-10-14
Learning objectives
- You read structural compatibility by shape.
- You apply width subtyping for sealed tables.
- You predict when one function value fits another slot.
- You state variance in plain words.
- You separate sealed from unsealed growth.
- You fix missing-required-prop errors.
Prerequisites
Shapes match by structure, not by name
If it carries the needed fields with the needed types, it fits. The declared name does not matter.
The precise rule is structural. The checker compares field sets and member types. Two aliases with the same fields accept the same values. This structural default is stated in the luau.org types intro, which was chosen because Lua tables are structural.
An unsealed literal knows exactly which fields it has. Extra or missing fields behave differently before and after sealing. You cannot treat every table as width-flexible at every point.
You pass different builders to one consumer when shapes align. You still fix real mismatches instead of renaming types to force a fit.
Put this Script in ServerScriptService.
luau
--!strict
export type Named = { name: string }
export type Runner = { name: string, speed: number }
local function greet(target: Named): string
return target.name
end
local runner: Runner = { name = "Mara", speed = 9 }
local got = greet(runner)This block checks clean. You infer got as string. Runner carries at least the Named fields. The consumer reads only name. Extra speed does not hurt.
Sealed tables allow width, unsealed tables track exactly
While you build, the checker tracks every key exactly. Once you seal, the checker allows extra keys downstream.
The precise rule splits states. Table literals start unsealed and gain exact knowledge as you add fields. An annotation, a function boundary, or leaving the creating scope seals the table. Sealed tables are inexact and support width subtyping: more fields may flow where fewer are required. Unsealed exactness, sealed inexactness, and width subtyping are defined in table types and sealed subtyping. All literals starting unsealed is defined in unsealed literals.
The limitation is growth after sealing. New fields on a sealed table error. Assigning fresh literals to an unsealed holder adds optional props instead of replacing the type, as defined in optional prop assignment.
You finish construction before you annotate or return. You pass wide values to narrow consumers. You do not grow shared records after they escape.
Put this Script in ServerScriptService.
luau
--!strict
local pet = { name = "Pip" }
pet.hunger = 2
local got = pet.hunger + 1This block checks clean. You infer pet as unsealed { name: string } then { name: string, hunger: number } after the add. Literals start unsealed, so the new field extends the type. got is number.
Put this Script in ServerScriptService.
luau
--!strict
export type Named = { name: string }
local wide: Named = { name = "Mara", speed = 9 }This block errors under the older exact reading but passes under width subtyping for sealed tables. Modern sealed checks allow the extra speed where only name is required, per sealed subtyping. You read extra props on sealed targets as tolerated, not as typos. If your Studio flags this line, your solver predates the RFC rollout.
Put this Script in ServerScriptService.
luau
--!strict
export type Named = { name: string }
local named: Named = { name = "Mara" }
named.speed = 3This block errors. named is sealed by its annotation. New fields cannot extend it. You fix it by adding speed to the alias or by building the full literal before annotating.
Missing required props fail, extra sealed props pass
Required means present. Optional means maybe. Extra sealed means ignored downstream.
A required field must exist with a matching type. An optional field like title: string? may be absent or hold the member type. A sealed source with more fields still satisfies a narrower target through width subtyping.
The limitation is optionality source. A field becomes optional because you wrote ? or because literal reassignment added it as optional per optional prop assignment. You cannot read an optional as present without a guard from Narrowing and Guards.
You mark truly absent-capable fields optional. You keep required fields always present. Errors point at the missing key, not at distant code.
Put this Script in ServerScriptService.
luau
--!strict
export type Pet = { name: string, hunger: number? }
local a: Pet = { name = "Pip" }
local b: Pet = { name = "Pip", hunger = 2 }This block checks clean. You infer both as Pet. hunger may be absent. A record missing name would error because that key is required.
Put this Script in ServerScriptService.
luau
--!strict
export type Pet = { name: string, hunger: number }
local bad: Pet = { name = "Pip" }This block errors. The checker reports a missing required prop. You fix it by adding hunger or by marking hunger optional in the alias when absence is valid.
Functions fit when they need less and return more
A handler fits a slot when it can handle what the caller sends and sends back what the caller wants.
The precise rule in plain form: a function value fits where the caller passes no more than it accepts and where its returns satisfy the wanted returns. Fewer params often fit because extra call args can be ignored. Narrower returns fit where wider returns are wanted only when the narrower type is a subtype of the wider one.
Multi-return arity, optional params, and variadics interact with this idea. You verify tricky slots with a small call sample instead of memorizing a table.
You pass a one-arg visitor where a two-arg loop offers index plus value. The visitor ignores the extra arg. You do not add unused params to force a match.
Put this Script in ServerScriptService.
luau
--!strict
local function each(list: { string }, visit: (item: string) -> ())
for _, item in list do
visit(item)
end
end
local function log(item: string)
print(item)
end
each({ "a", "b" }, log)This block checks clean. log needs one arg. each supplies one arg. The shapes align exactly. A visitor needing two args would error because one supplied arg cannot satisfy two required params.
Put this Script in ServerScriptService.
luau
--!strict
local function make(): { name: string, level: number }
return { name = "Mara", level = 4 }
end
local read: () -> { name: string } = makeThis block checks clean. You infer read as a narrower view of make. The wider return carries at least the wanted field. Callers of read see only name.
Variance in plain words
Some positions accept wider values. Others accept narrower ones. Returns and params point opposite ways.
The precise rule plainly: returns are covariant because a narrower result still satisfies a wider reader. Params are contravariant in theory because a handler accepting wider inputs still handles narrower calls, per the October 2020 Luau recap. Sealed table width follows the same reader idea: a wider table satisfies a narrower reader.
The checker reports a conversion failure at the boundary, not the words covariant or contravariant. You debug the field or arg it names, not the theory label.
You expect extra table fields to pass downstream. You expect narrower returns to pass upstream. You expect param mismatches to fail at the call, so you align handler params with what callers send.
Put this Script in ServerScriptService.
luau
--!strict
export type Short = { name: string }
export type Long = { name: string, level: number }
local long: Long = { name = "Mara", level = 4 }
local short: Short = longThis block checks clean. You infer short as Short. The wider Long flows to the narrower reader. Reverse the assignment and the checker reports a missing level.
Common mistakes
Wrong: you grow a sealed table.
Put this Script in ServerScriptService.
luau
--!strict
export type Pet = { name: string }
local pet: Pet = { name = "Pip" }
pet.hunger = 1This block errors. The annotation seals pet. You fix it by extending the alias or by building unsealed first.
luau
--!strict
local pet = { name = "Pip" }
pet.hunger = 1This fix checks clean. You infer pet as { name: string, hunger: number }. No annotation seals it before growth.
Wrong: you omit a required field.
Put this Script in ServerScriptService.
luau
--!strict
export type Sword = { name: string, damage: number }
local blade: Sword = { name = "Dune" }This block errors. damage is required but absent. You fix it by adding the field.
luau
--!strict
export type Sword = { name: string, damage: number }
local blade: Sword = { name = "Dune", damage = 7 }This fix checks clean. You infer blade as Sword.
Wrong: you demand more params than the caller supplies.
Put this Script in ServerScriptService.
luau
--!strict
local function each(list: { string }, visit: (item: string) -> ())
for _, item in list do
visit(item)
end
end
local function needy(item: string, index: number): ()
print(item)
end
each({ "a" }, needy)This block errors. each supplies one arg but needy requires two. You fix it by accepting only what callers send.
luau
--!strict
local function each(list: { string }, visit: (item: string) -> ())
for _, item in list do
visit(item)
end
end
local function calm(item: string)
print(item)
end
each({ "a" }, calm)This fix checks clean. The handler needs no more than the loop provides.
Exercises
- Pass a wide record where a narrow record is wanted.
- Grow an unsealed literal with a new field.
- Seal a table with an annotation, then show growth failing.
- Fix a missing-required-prop error with an optional or a value.
- Pass a one-param handler to a one-arg loop.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
export type Named = { name: string }
local function hello(target: Named): string
return target.name
end
local got = hello({ name = "Mara", level = 9 })This block checks clean under width subtyping. You infer got as string. The literal carries more than hello reads. Narrow readers accept wide sealed values.
Solution 2
Put this Script in ServerScriptService.
luau
--!strict
local bag = { coins = 2 }
bag.gems = 1
local total = bag.coins + bag.gemsThis block checks clean. You infer bag as { coins: number, gems: number } after the add. The literal starts unsealed, so growth extends the type.
Solution 3
Put this Script in ServerScriptService.
luau
--!strict
export type Bag = { coins: number }
local bag: Bag = { coins = 2 }This block checks clean and seals bag. Add bag.gems = 1 below and that line errors. The annotation locks the shape. You add new keys to the alias first.
Solution 4
Put this Script in ServerScriptService.
luau
--!strict
export type Pet = { name: string, hunger: number? }
local a: Pet = { name = "Pip" }
local b: Pet = { name = "Pip", hunger = 3 }This block checks clean. You infer both as Pet. The optional marker makes absence legal. Without ?, the first line would report a missing prop.
Solution 5
Put this Script in ServerScriptService.
luau
--!strict
local function each(list: { number }, visit: (n: number) -> ())
for _, n in list do
visit(n)
end
end
local seen = 0
each({ 1, 2 }, function(n)
seen += n
end)This block checks clean. The handler needs exactly what the loop sends. No extra required params appear.
Summary
- Compatibility is structural by shape.
- Sealed tables support width: wide values satisfy narrow readers.
- Literals start unsealed and grow exactly until sealed.
- Missing required props error. Optionals allow absence.
- Functions fit when they need no more than callers send and return no less than callers want.
- Next: Type Packs and Variadics.