---
url: /rfcs/type-system-rfcs.md
description: Sealed tables, never and unknown, casts, and attributes with edge cases.
---

# Type System RFCs

*Written: {{ $frontmatter.date }}*

## 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](../luau-basics/tables-in-depth.md)
* [Narrowing and Guards](../strict-typing/narrowing-and-guards.md)
* [Casts and Assertions](../strict-typing/casts-and-assertions.md)
* [Subtyping and Compatibility](../strict-typing/subtyping-and-compatibility.md)
* [How to read RFC status](./implemented.md)

## 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](https://rfcs.luau.org/sealed-table-subtyping.html) 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 Maya
```

This 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
Maya
```

The 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 Elder
```

You 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](https://rfcs.luau.org/sealed-table-subtyping.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## 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](https://rfcs.luau.org/unsealed-table-literals.html) 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 3
```

The 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 3
```

The 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 40
```

You 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](https://rfcs.luau.org/unsealed-table-literals.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## 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](https://rfcs.luau.org/unsealed-table-assign-optional-property.html) 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
2
```

Direct 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)
end
```

```txt
2
```

The 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.")
end
```

```txt
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](https://rfcs.luau.org/unsealed-table-assign-optional-property.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## 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](https://rfcs.luau.org/unsealed-table-subtyping-strips-optional-properties.html) 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
Maya
```

An 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
Maya
```

The 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 sword
```

The 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](https://rfcs.luau.org/unsealed-table-subtyping-strips-optional-properties.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## 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](https://rfcs.luau.org/never-and-unknown-types.html) 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
25
```

The 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
count
```

The 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
15
```

Each 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](https://rfcs.luau.org/never-and-unknown-types.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## 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](https://rfcs.luau.org/syntax-type-ascription-bidi.html) 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
Maya
```

The downcast narrows the union. This direction always worked.

After:

```luau
--!strict
local name = "Maya"
local wide = name :: number | string
print(wide)
```

```txt
Maya
```

The 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 3
```

Width 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](https://rfcs.luau.org/syntax-type-ascription-bidi.html) shows **Status**: Implemented. You use it today. You still confirm it in Studio per [how to read status](./implemented.md).

## 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](https://rfcs.luau.org/syntax-attributes-functions.html) defines the syntax, and the [attributes reference](https://luau.org/attributes) 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
50
```

The 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
50
```

The 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
10
```

You 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](https://rfcs.luau.org/syntax-attributes-functions.html) shows **Status**: Implemented, and the [attributes reference](https://luau.org/attributes) documents the set. You use them today. You still confirm them in Studio per [how to read status](./implemented.md).

## 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)
end
```

The 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

1. Pass a rich record into a small parameter.
2. Grow a literal after construction.
3. Reassign a table to add an optional and narrow it.
4. Write an exhaustive branch that names never.
5. Widen with an upcast and narrow with a downcast.
6. Deprecate a helper with a replacement pointer.

::: details 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 sword
```

The literal carries an extra price. The parameter wants only the title. Width subtyping accepts the call.
:::

::: details 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 150
```

The literal starts with one field. The assignment adds the second. The type grows with the table.
:::

::: details 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)
end
```

```txt
40
```

The reassignment adds reward as optional. The explicit nil check narrows it. The print sees a number.
:::

::: details 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
on
```

Both boolean cases return. The else holds never. Never fits the string return.
:::

::: details 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
Theo
```

The upcast widens into the optional. The downcast narrows back. Both prints see the value.
:::

::: details 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 sword
```

The 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](./type-functions.md).
