Appearance
Type System RFCs
Written: 2026-10-14
Learning objectives
- You pass wider tables where narrower ones are expected.
- You grow tables during construction.
- You model missing data with never and unknown.
- You cast in both directions within one rule.
- You mark functions native and deprecated.
Prerequisites
- Tables in Depth
- Narrowing and Guards
- Casts and Assertions
- Subtyping and Compatibility
- How to read RFC status
Sealed table subtyping
They support width subtyping. You use it for interfaces and implementations.
Original problem
A table with extra fields failed where a smaller shape was expected. An implementation carrying an id could not flow into a parameter that wanted only a name. Authors stripped fields by hand or duplicated shapes.
Change and motivation
A sealed table with every required property now fits the smaller sealed type. Extra properties stay allowed. The sealed subtyping RFC makes the change strictly more permissive, so old code keeps checking.
Implemented semantics
You annotate the target with the smaller shape. You pass the larger value. The checker verifies presence and types of required fields and ignores extras. Missing or mistyped fields still error.
Unlike exact matching, extras no longer fail. Unlike any, required fields still check fully.
Before and after
Before:
luau
--!strict
type Named = { name: string }
local function greet(who: Named): string
return `Hi {who.name}`
end
local actor = { name = "Maya", id = 7 }
print(greet(actor))txt
Hi MayaThis passes today because the literal widens into the parameter. The old exact rule rejected the equivalent annotated flow.
After:
luau
--!strict
type Named = { name: string }
type Actor = { name: string, id: number }
local actor: Actor = { name = "Maya", id = 7 }
local smaller: Named = actor
print(smaller.name)txt
MayaThe larger annotated value flows into the smaller annotation. No field stripping appears.
Realistic example
You hand quest givers to a UI function that only reads names.
luau
--!strict
type Card = { name: string }
local function showCard(card: Card): string
return `Quest from {card.name}`
end
type Giver = { name: string, zone: string, reward: number }
local elder: Giver = { name = "Elder", zone = "forest", reward = 50 }
print(showCard(elder))txt
Quest from ElderYou define the small card. You define the rich giver. You pass the giver straight in. The UI reads only what it needs.
Strict implications
You annotate parameters with the smallest shape you truly need. Callers keep rich types. The checker still catches a missing name or a mistyped one. You never silence those errors with a cast.
Edge cases
Extra fields do not weaken required ones. A wrong id type still errors even when id is extra. Functions that write unknown fields still need precise types, since width subtyping governs reading through the smaller shape.
Status
The sealed table subtyping RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Unsealed table literals
Every table literal starts unsealed. You use this while you build tables step by step.
Original problem
Only the empty literal stayed open for new fields. A literal with initial fields sealed immediately. Adding a method or a second field after construction failed. Authors emptied their literals or predeclared every field to please the checker.
Change and motivation
All table literals start unsealed now. You add fields after construction and the type grows. The unsealed literals RFC removes errors without changing runtime behavior.
Implemented semantics
You create the literal with some fields. You assign new fields on later lines. Each assignment extends the inferred shape. The table seals when it flows into an annotated sealed position.
Unlike the old rule, nonempty literals stay open. Unlike any, every known field keeps a precise type.
Before and after
Before:
luau
--!strict
local pet = {}
pet.name = "Pip"
pet.level = 3
print(`{pet.name} at {pet.level}`)txt
Pip at 3The empty start kept the table open. Authors shaped all construction this way.
After:
luau
--!strict
local pet = { name = "Pip" }
pet.level = 3
print(`{pet.name} at {pet.level}`)txt
Pip at 3The nonempty start stays open too. The second assignment extends the shape.
Realistic example
You build a mob record with data first and behavior second.
luau
--!strict
local mob = { name = "Slime", health = 40 }
function mob.describe(): string
return `{mob.name} holds {mob.health}`
end
print(mob.describe())txt
Slime holds 40You set data fields. You attach the method. You call it. Nothing forces you to empty the literal first.
Strict implications
Growth is tracked per program point. A use before the assignment still errors, which catches typos. You keep construction linear so every read lands after its write. You annotate the final shape when the table crosses a module boundary.
Edge cases
Open literals catch fewer typos in field names. A misspelled assignment creates a fresh field instead of erroring. Array-like literals accept named fields now, so you review those additions. Sealed annotations still close the table at the boundary.
Status
The unsealed table literals RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Unsealed assignment of optional properties
Reassigning an unsealed table can introduce optional fields. You use this for test doubles and staged data.
Original problem
Assigning a fresh literal over an unsealed table ignored new properties on read. Test setups that swapped payloads between stages failed to check. Each stage needed its own local or a hand-written optional.
Change and motivation
Assigning a literal to an unsealed table now adds unknown properties as optional fields. Later stages can supply or omit them. The optional assignment RFC models each newcomer as possibly absent so every stage checks.
Implemented semantics
You start with a base literal. You assign a literal with a new field. The table type gains that field as an optional. A third assignment with another new field adds a second optional. Reads of either field see an optional type.
Unlike field extension, newcomers arrive optional, not required. Unlike indexers, each key keeps its own type.
Before and after
Before:
luau
--!strict
local slot = { item = "sword" }
slot.count = 2
print(slot.count)txt
2Direct extension adds a required field. Reassignment through a fresh literal is the new case.
After:
luau
--!strict
local slot = { item = "sword" }
slot = { item = "bow", count = 2 }
if slot.count ~= nil then
print(slot.count)
endtxt
2The reassignment introduces count as optional. The guard narrows it.
Realistic example
You rotate shop stock between rounds while keeping one local.
luau
--!strict
local stock = { item = "sword" }
stock = { item = "bow", discount = 10 }
local saving = stock.discount
if saving ~= nil then
print(`Save {saving} coins.`)
else
print("No sale.")
endtxt
Save 10 coins.You open with one item. You swap in a new item plus a discount. The discount reads as optional. The branch handles both cases.
Strict implications
You narrow every introduced optional before math or calls. Truthiness fits booleans, explicit nil checks fit values. You never assume a stage filled the field, because the type promises it may be absent.
Edge cases
Repeated reassignment accumulates optionals from every stage. That shape can surprise readers, so you prefer distinct locals for wildly different payloads. Deep nesting adds optionals level by level, which you flatten when the shape stabilizes.
Status
The unsealed table assignment RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Stripping optionals in subtyping
Optional stripping now applies only from unsealed to sealed types. You use this rule to keep assignments sound.
Original problem
Subtyping stripped optional fields whenever the target was sealed. A sealed source missing an optional could flow into a target that promised it, then a reader met a mistyped value. The old rule hid the mismatch.
Change and motivation
Stripping now requires an unsealed source and a sealed target. Precise construction sites still simplify into annotated shapes. Sealed values no longer shed optionals silently. The stripping RFC pairs with the literals change to close the hole.
Implemented semantics
You build with a literal, which is unsealed. You assign into a sealed annotation that omits an optional. That flow checks. You assign one sealed value into another sealed type that drops an optional. That flow errors.
Unlike the old rule, the source state matters. Unlike width subtyping, dropping a promised optional is not widening.
Before and after
Before:
luau
--!strict
local draft = { name = "Maya", title = "scout" }
local fixed: { name: string } = draft
print(fixed.name)txt
MayaAn unsealed source simplifies into the smaller sealed target. This flow stays legal.
After:
luau
--!strict
local draft = { name = "Maya", title = "scout" }
local fixed: { name: string } = draft
local name: string = fixed.name
print(name)txt
MayaThe same legal flow appears with the value used as a string. The point stands: unsealed sources may simplify, sealed sources may not shed.
Realistic example
You accept a loadout draft and store the stable part.
luau
--!strict
type Stored = { slot: string }
local function store(item: Stored): string
return `Stored {item.slot}`
end
local draft = { slot = "sword", skin = "gold" }
print(store(draft))txt
Stored swordThe draft literal is unsealed. The parameter is sealed and smaller. The call checks. A sealed variable with a mistyped extra would not pass silently.
Strict implications
You construct payloads as literals near the call when you want simplification. You annotate stored values early when you want the checker to hold every field. You read errors about stripped optionals as soundness reports, not noise.
Edge cases
Two sealed values with different optionals can refuse to flow even when the values look compatible. You convert through a fresh literal or widen the target annotation. This strictness is intentional and guards writes through the smaller shape.
Status
The optional stripping RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Never and unknown
They anchor the type lattice. You use them for impossible cases and unshaped input.
Original problem
Any filled both roles. It stood in for every type and no type at once, which switched checking off. Helpers that returned unknown shapes used any and lost all safety. Exhaustive branches had no bottom to name the impossible case.
Change and motivation
Unknown holds every value and demands narrowing before use. Never holds no value and fits everywhere as a subtype. The never and unknown RFC separates safe top from switch-off any. Nonstrict mode still treats unknown loosely, while strict mode enforces narrowing.
Implemented semantics
You annotate unclear input as unknown. You guard with typeof, truthiness, or singleton equality before use. You annotate impossible results as never. Functions that always error return never. Empty intersections and exhaustive else branches hold never.
Unlike any, unknown checks while any does not. Unlike nil, unknown accepts everything while nil accepts one value.
Before and after
Before:
luau
--!strict
local function coins(raw: unknown): number
if typeof(raw) == "number" then
return raw
end
return 0
end
print(coins(25))txt
25The unknown input narrows through typeof. Numbers pass. Everything else becomes 0.
After:
luau
--!strict
local function kind(value: string | number): string
if typeof(value) == "string" then
return "text"
elseif typeof(value) == "number" then
return "count"
else
local impossible: never = value
return impossible
end
end
print(kind(7))txt
countThe else branch holds never because both cases are handled. Never returns where a string is expected because it fits everywhere.
Realistic example
You validate a remote payload that may hold anything.
luau
--!strict
local function priceOf(raw: unknown): number
if typeof(raw) == "number" then
return raw
end
return 0
end
local function total(cart: { unknown }): number
local sum = 0
for _, item in cart do
sum += priceOf(item)
end
return sum
end
print(total({ 10, "Maya", 5 }))txt
15Each cart item arrives unknown. The helper narrows it. Strings contribute 0. Numbers add up.
Strict implications
Unknown must narrow before use in strict mode. Arithmetic, indexing, and calls on raw unknown all error. Never needs no construction because you never build one; you receive it from exhaustive narrowing or error paths. You prefer unknown plus guards over any for every untrusted input.
Edge cases
A pack containing never collapses, so multi-return code after an error call types accordingly. Unknown in nonstrict behaves like any, so you test boundary code in strict. Intersecting unrelated types yields never, which reports at the annotation rather than at each later use.
Status
The never and unknown RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Bidirectional casts
Casts convert between related types in either direction. You use them at drifted boundaries.
Original problem
Casts allowed only downcasts plus any. Narrowing a union worked, but widening a value into a union failed. Authors who needed the widening direction had no honest spelling.
Change and motivation
The operator now permits a cast when either side is a subtype of the other. Downcasts narrow. Upcasts widen. Unrelated pairs still error. The bidirectional cast RFC keeps the operator sound while covering both daily needs.
Implemented semantics
You write value double colon type. The checker tries both directions. When one direction relates the types, the cast passes with no runtime effect. When neither direction relates them, it errors. Casting through any stays available for genuinely external shapes.
Unlike the old rule, upcasts pass. Unlike a free coercion, unrelated types still fail.
Before and after
Before:
luau
--!strict
local mixed: number | string = "Maya"
local text = mixed :: string
print(text)txt
MayaThe downcast narrows the union. This direction always worked.
After:
luau
--!strict
local name = "Maya"
local wide = name :: number | string
print(wide)txt
MayaThe upcast widens one member into its union. The old rule rejected it. The new rule accepts it.
Realistic example
You store a concrete pet where the shelf expects the small shape, then recover the concrete shape at a checked boundary.
luau
--!strict
type Shelf = { name: string }
local pet = { name = "Pip", level = 3 }
local onShelf: Shelf = pet
local back = onShelf :: { name: string, level: number }
print(`{back.name} at {back.level}`)txt
Pip at 3Width subtyping places the pet on the shelf. The downcast recovers the level where you know the concrete shape. Each step checks one direction.
Strict implications
You cast across one relationship at a time. You keep casts narrow and local to the drifting call. You fix arity mismatches and missing fields instead of casting around them. You verify Engine signatures live before you blame the checker.
Edge cases
The operator truncates packs, so you never cast a multi-return call inside another call. A cast between unrelated types still errors, which catches swapped arguments. A later inhabitance proposal would widen the rule further, but it is NOT YET AVAILABLE, do not run, so you stay within the bidirectional rule.
Status
The bidirectional cast RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Function attributes
Attributes tune functions without changing their bodies. You use them for hot paths and retirements.
Original problem
Native compilation applied per file or not at all. Hot functions could not request native code on their own. Retired helpers had no standard marker, so lints could not warn callers.
Change and motivation
Functions accept at-sign attributes before their declarations. Native requests native compilation for that function. Deprecated marks it retired, with optional replacement and reason parameters. The attributes RFC defines the syntax, and the attributes reference documents native, deprecated, and parameters.
Implemented semantics
You place attributes directly before the function. Multiple attributes share one line. Deprecated accepts use and reason keys that shape the warning. Native takes no parameters and does not recurse into inner functions.
Unlike comment directives, attributes are syntax the compiler reads. Unlike decorators, only built-in attributes exist.
Before and after
Before:
luau
--!strict
local function mix(a: number, b: number, t: number): number
return a + (b - a) * t
end
print(mix(0, 100, 0.5))txt
50The helper works but gives the compiler no hint. Hot loops leave performance on the table.
After:
luau
--!strict
@native
local function mix(a: number, b: number, t: number): number
return a + (b - a) * t
end
print(mix(0, 100, 0.5))txt
50The attribute requests native compilation. The body is unchanged.
Realistic example
You retire an old payout helper while pointing callers at the new one.
luau
--!strict
local Bank = {}
@[deprecated({ use = "Bank.grant()", reason = "Old path skips receipts." })]
function Bank.pay(player: Player, amount: number): number
return amount
end
function Bank.grant(player: Player, amount: number): number
return amount
end
print(Bank.grant(game:GetService("Players").LocalPlayer :: Player, 10))txt
10You mark the old entry with its replacement and reason. You keep it working during migration. New code calls the new entry. The linter warns at old call sites.
Strict implications
Attributes never change types. A deprecated function keeps its signature and still checks. Native never changes semantics, only code generation. You keep annotations complete on attributed functions because tools read them harder, not less.
Edge cases
Attributes attach to the function being declared, so placement on table members needs care in reading. Native on an outer function does not mark inner functions. Parameterized attributes take literal values only, so you pass strings and tables, not computed expressions.
Status
The attributes RFC shows Status: Implemented, and the attributes reference documents the set. You use them today. You still confirm them in Studio per how to read status.
Common mistakes
Wrong: you expect extras to vanish silently.
luau
--!strict
type Tiny = { name: string }
local rich = { name = "Maya", id = 7 }
local tiny: Tiny = rich
print(tiny.name)This flow is correct today. The mistake is relying on it to hide a mistyped extra. Fix it by keeping extras well typed anyway.
Wrong: you read a reassigned field without a guard.
luau
--!strict
local stock = { item = "sword" }
stock = { item = "bow", discount = 10 }
local saving = stock.discount
if saving ~= nil then
print(saving)
endThe guard is correct. The mistake is printing the raw optional. Fix it by narrowing every introduced optional.
Wrong: you cast to silence a missing field.
luau
--!strict
local shelf = { name = "Pip" }
local wide = shelf :: { name: string, level: number }
print(wide.name)This cast fails because neither direction relates the types. Fix it by adding the field or widening honestly through construction.
Wrong: you mark everything native.
luau
--!strict
@native
local function add(a: number, b: number): number
return a + b
end
print(add(2, 3))The attribute works but changes nothing here. Fix it by reserving native for measured hot paths.
Exercises
- Pass a rich record into a small parameter.
- Grow a literal after construction.
- Reassign a table to add an optional and narrow it.
- Write an exhaustive branch that names never.
- Widen with an upcast and narrow with a downcast.
- Deprecate a helper with a replacement pointer.
Solution 1
Run this pure snippet in the Command Bar.
luau
--!strict
type Label = { title: string }
local function tag(value: Label): string
return `Tag {value.title}`
end
print(tag({ title = "sword", price = 150 }))txt
Tag swordThe literal carries an extra price. The parameter wants only the title. Width subtyping accepts the call.
Solution 2
Run this pure snippet in the Command Bar.
luau
--!strict
local cart = { item = "bow" }
cart.price = 150
print(`{cart.item} costs {cart.price}`)txt
bow costs 150The literal starts with one field. The assignment adds the second. The type grows with the table.
Solution 3
Run this pure snippet in the Command Bar.
luau
--!strict
local quest = { name = "Herbs" }
quest = { name = "Herbs", reward = 40 }
if quest.reward ~= nil then
print(quest.reward)
endtxt
40The reassignment adds reward as optional. The explicit nil check narrows it. The print sees a number.
Solution 4
Run this pure snippet in the Command Bar.
luau
--!strict
local function label(value: boolean): string
if value then
return "on"
elseif not value then
return "off"
else
local impossible: never = value
return impossible
end
end
print(label(true))txt
onBoth boolean cases return. The else holds never. Never fits the string return.
Solution 5
Run this pure snippet in the Command Bar.
luau
--!strict
local name = "Theo"
local wide = name :: string?
local back = wide :: string
print(wide)
print(back)txt
Theo
TheoThe upcast widens into the optional. The downcast narrows back. Both prints see the value.
Solution 6
Run this pure snippet in the Command Bar.
luau
--!strict
local Shop = {}
@deprecated
function Shop.buy(name: string): string
return `Bought {name}`
end
function Shop.checkout(name: string): string
return `Bought {name}`
end
print(Shop.checkout("sword"))txt
Bought swordThe old entry carries the marker. The new entry does the work. Callers see the warning at old sites.
Summary
- Wide tables fit narrow shapes. Required fields still check.
- Literals start open. Reassignment adds optionals.
- Stripping optionals needs an unsealed source and a sealed target.
- Unknown narrows. Never marks the impossible.
- Casts run both directions across one relationship.
- Attributes tune compilation and retirement.
- Next: Type Functions.