Appearance
Generics and Packs RFCs
Written: 2026-10-14
Learning objectives
- You name function type arguments for readers and tools.
- You type variadic parameters and returns.
- You alias packs and reuse them.
- You set defaults on type alias parameters.
- You pin generic calls to exact types.
Prerequisites
Named function type arguments
They document callback shapes. You use them in shared module contracts.
Original problem
Function types listed bare types in order. A reader saw string comma number and guessed which was which. Tooltips showed the same bare list. Large callbacks became easy to wire wrongly.
Change and motivation
Function types accept optional argument names with the same shape as declarations. You write name colon type inside the parentheses. Names travel with the type for documentation and tooling. Comparisons ignore them. The named arguments RFC changes no checking semantics.
Implemented semantics
You may name any subset of arguments. You write cost colon number where you want a label. Variadic tails stay unnamed and keep their existing shape. Names survive in aliases, declares, variables, and parameters. Two function types that differ only by names remain the same type to the checker.
Unlike a comment, tools can read the names. Unlike a parameter rename, it never changes which calls check.
Before and after
Before:
luau
--!strict
type Grant = (number, string) -> string
local function runGrant(grant: Grant): string
return grant(10, "Maya")
end
print(runGrant(function(amount: number, player: string): string
return `{player} gained {amount}`
end))txt
Maya gained 10The alias works but hides meaning. A reader must open the implementation to learn the order.
After:
luau
--!strict
type Grant = (amount: number, player: string) -> string
local function runGrant(grant: Grant): string
return grant(10, "Maya")
end
print(runGrant(function(amount: number, player: string): string
return `{player} gained {amount}`
end))txt
Maya gained 10The alias now teaches the order. Tooltips show the names. Calls stay identical.
Realistic example
You publish a purchase validator that other modules implement.
luau
--!strict
type Validate = (buyer: Player, price: number) -> boolean
local validators: { Validate } = {}
table.insert(validators, function(buyer: Player, price: number): boolean
local funds = buyer:GetAttribute("Coins")
if funds ~= nil and typeof(funds) == "number" then
return funds >= price
end
return false
end)
print(#validators)txt
1You name both arguments in the alias. You store one implementation. The names tell the next author what each position means. The checker still enforces the types.
Strict implications
Names never loosen checking. A wrong argument type still errors. You keep names accurate because a stale name misleads every reader. You still annotate the implementation fully, since the alias names do not annotate the function body.
Edge cases
You may mix named and unnamed arguments in one list. Names must be identifiers followed by a colon, so a type that starts with an identifier needs care in reading. Copying a named alias into a second alias keeps the names, which is usually what you want.
Status
The named function type arguments RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Typed variadics
They describe open-ended argument lists. You use them for math helpers, loggers, and event buses.
Original problem
You could accept any number of extras with bare dots, but you could not say their type. Helpers that wanted only numbers accepted anything. Callers found out at runtime. Library authors wrote overloads or gave up and used any.
Change and motivation
Declarations accept dots colon type for the tail. Type positions accept dots type. Both describe any number of values of one type, including zero. The typed variadics RFC adds no runtime behavior. Annotations still erase.
Implemented semantics
You write three dots colon number as the last parameter of a function. You write dots string as the last parameter or return tail of a function type. The tail must stay last. A call with zero extras fits. A call with a wrong extra fails.
Unlike an array parameter, callers pass values directly with no table wrapper. Unlike any variadics, the checker enforces every extra.
Before and after
Before:
luau
--!strict
local function total(...: number): number
local sum = 0
for _, value in { ... } do
sum += value
end
return sum
end
print(total(4, 6))txt
10This already uses the new shape because the old shape cannot express it. The equivalent old code took untyped dots and checked each value by hand.
After:
luau
--!strict
type Sum = (...number) -> number
local sum: Sum = function(...: number): number
local running = 0
for _, value in { ... } do
running += value
end
return running
end
print(sum(4, 6, 5))txt
15The alias names the contract. The implementation matches it. Wrong extras now error at the call.
Realistic example
You broadcast a numeric score event to any number of round totals.
luau
--!strict
local Board = {}
function Board.addScores(name: string, ...: number): string
local sum = 0
for _, score in { ... } do
sum += score
end
return `{name} scored {sum}`
end
print(Board.addScores("Maya", 10, 20, 5))txt
Maya scored 35You fix the first parameter as a string. You gather the rest as numbers. You sum them with a direct loop. The message prints the total.
Strict implications
You annotate the tail explicitly. The checker then guards every extra argument. You collect extras into a table with braces and dots when you need to loop them. You keep the tail last, or parsing fails.
Edge cases
Named variadics do not exist yet, so dots name colon type is not a feature. An empty call fits a typed tail, which surprises authors who expect at least one value. You add an explicit first parameter when one value is mandatory.
Status
The typed variadics RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Type alias packs
One alias carries a whole argument or return list. You use it for signals, result tuples, and wrappers.
Original problem
You could declare a generic pack on an alias but you could not reference it in the body or instantiate it cleanly. Aliases that wanted to forward packs had no spelling. Callers could not pass zero, one, or many types into a pack slot with predictable rules.
Change and motivation
Pack names in scope now work inside alias bodies. You forward them into function types and other aliases. Instantiation assigns leading types to leading parameters and packs to pack slots. Explicit parenthesized packs add control when several packs interact. The alias packs RFC defines the matching order.
Implemented semantics
You declare packs with name dots in the parameter list. You reference them with the same spelling in the body. Missing arguments fill the first pack from the leftover types. A pack after the first must receive a pack, not loose types. Variadic types convert into packs at instantiation. Empty angle brackets can mean an empty pack where a pack is expected.
Unlike plain generics, one name carries a list, not one type. Unlike overloads, matching is positional and eager.
Before and after
Before:
luau
--!strict
type Handler<Args...> = {
connect: ((Args...) -> ()) -> (),
fire: (Args...) -> (),
}
local function make<Args...>(handler: Handler<Args...>): Handler<Args...>
return handler
end
local inner: Handler<number, string> = {
connect = function(_: (number, string) -> ()): () end,
fire = function(_: number, _: string): () end,
}
print(make(inner) == inner)txt
trueThe alias declares a pack and uses it in two methods. The maker forwards the pack untouched.
After:
luau
--!strict
type Box<Content...> = (Content...) -> (Content...)
type PairBox = Box<(number, string)>
local pair: PairBox = function(first: number, second: string): (number, string)
return first, second
end
local first, second = pair(3, "Maya")
print(first)
print(second)txt
3
MayaThe explicit pack groups two types as one argument. The function returns both. The caller destructures them.
Realistic example
You model a damage signal that carries a player and an amount.
luau
--!strict
type Signal<Args...> = {
fire: (Args...) -> (),
connect: ((Args...) -> ()) -> (),
}
local damage: Signal<Player, number> = {
fire = function(_: Player, _: number): () end,
connect = function(_: (Player, number) -> ()): () end,
}
damage.fire(game:GetService("Players").LocalPlayer :: Player, 10)
print("fired")txt
firedYou define the generic signal once. You instantiate it with two types. Both methods share the pack.
Strict implications
Packs flow through annotations exactly. A wrong arity at instantiation is an error at the use, not deep inside the alias. You keep pack names consistent between declaration and body. You annotate empty cases explicitly when readability matters.
Edge cases
You cannot split one element off a pack variable during instantiation. A definition that needs the head type plus the tail pack has no eager spelling. Multiple packs demand explicit pack arguments after the first. A bare empty instantiation can be ambiguous, so you prefer the explicit empty pack where the alias takes several packs.
Status
The type alias type packs RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Default alias parameters
They shorten common instantiations. You use them for config objects and resolver types.
Original problem
Aliases with several parameters forced every use to list every argument. Authors skipped the noise by writing any. Refactors touched every use. Common cases paid for rare customization.
Change and motivation
Alias parameters accept equals plus a type as a default. Missing trailing arguments fall back to their defaults. Defaults may reference earlier parameters. Packs accept defaults too, including empty and variadic shapes. The default parameters RFC leaves function generics unchanged, since calls cannot pass explicit arguments there.
Implemented semantics
You write name equals type inside the parameter list. Once one parameter has a default, every parameter to its right needs one. A default may name parameters on its left only. When every parameter has a default, you may reference the alias bare or with empty brackets.
Unlike an optional union member, the default fills the slot at the use. Unlike overloads, one definition covers all arities.
Before and after
Before:
luau
--!strict
type Entry<T, Meta> = { value: T, meta: Meta }
local sword: Entry<string, boolean> = { value = "sword", meta = true }
print(sword.value)txt
swordEvery use names both arguments. Short uses stay verbose.
After:
luau
--!strict
type Entry<T, Meta = boolean> = { value: T, meta: Meta }
local sword: Entry<string> = { value = "sword", meta = true }
print(sword.value)txt
swordThe second parameter defaults to boolean. The common use names one argument. Full customization still works.
Realistic example
You describe resolvers with a common argument map but allow overrides.
luau
--!strict
type Resolver<Source, Args = { [string]: string }> = (Source, Args) -> boolean
local admin: Resolver<Player> = function(_: Player, _: { [string]: string }): boolean
return true
end
local custom: Resolver<Player, { zone: string }> = function(_: Player, args: { zone: string }): boolean
return args.zone == "arena"
end
print(admin(game:GetService("Players").LocalPlayer :: Player, {}))
print(custom(game:GetService("Players").LocalPlayer :: Player, { zone = "arena" }))txt
true
trueThe first use takes the default map. The second names a precise map. Both calls check.
Strict implications
Defaults never weaken checking. The filled type must fit the value exactly as a written type would. You keep defaults honest, because a loose default spreads any through the codebase. You still annotate exported aliases fully at module boundaries.
Edge cases
A default cannot reference parameters on its right. Skipping a middle argument is impossible, so you order parameters from most to least commonly customized. A bare alias reference with all defaults can confuse readers, so you use it only for truly standard shapes.
Status
The default type alias type parameters RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Explicit instantiation
It pins a generic call to exact types. You use it when inference picks too wide or too narrow a type.
Original problem
Generic calls inferred types from values. A nil argument inferred nil instead of the intended optional. An empty table inferred an empty shape. Workarounds annotated the receiving local with the full return type or cast the argument through an unsound step. Both hid intent and added noise.
Change and motivation
Expressions accept double angle brackets with a type list. The list binds the leading generic parameters at that use. Unspecified trailing parameters stay inferred. The explicit instantiation RFC adds no runtime effect. Wrong targets and wrong arities are static errors.
Implemented semantics
You write value double open types double close, then call or store as usual. Index chains accept it on any prefix, including method calls. Partial lists bind from the left. The syntax closes the door on shift operators, which Luau omits in favor of the bit32 library.
Unlike a cast, it selects types without asserting through an unrelated shape. Unlike an annotation, it travels with the call instead of the receiving local.
Before and after
Before:
luau
--!strict
local function first<T>(values: { T }): T?
return values[1]
end
local answer: number? = first({ 10, 20 })
print(answer)txt
10You repeat the knowledge at the local. A reader must connect the annotation back to the call.
After:
luau
--!strict
local function first<T>(values: { T }): T?
return values[1]
end
local answer = first<<number>>({ 10, 20 })
print(answer)txt
10The call carries the binding. Inference handles the rest.
Realistic example
You create an empty typed list without a misleading annotation.
luau
--!strict
local function filled<T>(value: T): { T }
return { value }
end
local starter = filled<<string>>("sword")
local empty: { string } = {}
table.insert(empty, starter[1])
print(empty[1])
print(starter[1])txt
sword
swordYou pin the maker to string at the call. You keep the empty list annotation where a call cannot help. Both lists hold strings.
Strict implications
The checker verifies the explicit types against the generic bounds at the use. A wrong choice errors at the brackets, which beats a downstream mismatch. You still let inference work when values already determine the types, and you pin only the ambiguous calls.
Edge cases
The syntax applies to function values, not to metatable dispatch through unknown metamethods. You cannot instantiate through a value that is not a generic function. Over-pinning every call adds noise, so you reserve brackets for nil, empty, and widened cases.
Status
The explicit type parameter instantiation RFC shows Status: Implemented. You use it today. You still confirm it in Studio per how to read status.
Common mistakes
Wrong: you document callbacks with bare positions.
luau
--!strict
type Move = (number, number) -> ()
local step: Move = function(x: number, y: number): ()
print(x)
print(y)
end
step(1, 2)Names are missing, so readers guess the order. Fix it by naming both arguments in the alias.
Wrong: you wrap variadics in a table at the call.
luau
--!strict
local function add(...: number): number
local sum = 0
for _, value in { ... } do
sum += value
end
return sum
end
print(add(1, 2, 3))The call is correct here. The mistake is writing add of a table instead. Fix it by passing values directly.
Wrong: you skip a middle alias argument.
luau
--!strict
type Triple<A = string, B = string, C = string> = { A, B, C }
local one: Triple<string> = { "a", "b", "c" }
print(one[1])You wanted the third default changed while naming only the first. Positional fill cannot skip. Fix it by reordering parameters or naming all three.
Wrong: you cast where instantiation fits.
luau
--!strict
local function wrap<T>(value: T): { T }
return { value }
end
local boxed = wrap<<string>>("gem")
print(boxed[1])The bracket call is correct. The mistake is the older double cast through any. Fix it by pinning the call instead.
Exercises
- Name the arguments of a two-parameter callback alias.
- Write a typed variadic max for numbers.
- Forward a pack through a wrapper alias.
- Add a default to an alias and use both arities.
- Pin a generic call that receives nil.
Solution 1
Run this pure snippet in the Command Bar.
luau
--!strict
type Attack = (attacker: string, damage: number) -> string
local strike: Attack = function(attacker: string, damage: number): string
return `{attacker} deals {damage}`
end
print(strike("Maya", 12))txt
Maya deals 12You label both positions. The implementation matches by type.
Solution 2
Run this pure snippet in the Command Bar.
luau
--!strict
local function biggest(...: number): number
local best = 0
for _, value in { ... } do
if value > best then
best = value
end
end
return best
end
print(biggest(3, 9, 4))txt
9You gather any count of numbers. You track the largest.
Solution 3
Run this pure snippet in the Command Bar.
luau
--!strict
type Emit<Args...> = (Args...) -> ()
type Logged<Args...> = { emit: Emit<Args...>, count: number }
local two: Logged<number, string> = {
emit = function(_: number, _: string): () end,
count = 0,
}
print(two.count)txt
0You declare the pack once. You forward it into the method. Instantiation with two types checks both.
Solution 4
Run this pure snippet in the Command Bar.
luau
--!strict
type Chest<T, Label = string> = { item: T, label: Label }
local plain: Chest<number> = { item = 5, label = "coins" }
local fancy: Chest<number, boolean> = { item = 5, label = true }
print(plain.label)
print(fancy.item)txt
coins
5You default the label to string. The short use takes the default. The long use overrides it.
Solution 5
Run this pure snippet in the Command Bar.
luau
--!strict
local function make<T>(value: T): { T }
return { value }
end
local boxed = make<<string?>>(nil)
print(boxed[1])txt
nilYou pin the call to an optional string. Inference alone would pick nil.
Summary
- Named arguments document function types. Checking ignores them.
- Typed variadics guard open tails. Calls pass values directly.
- Alias packs forward lists. Matching is positional and eager.
- Defaults shorten common uses. Trailing parameters need defaults too.
- Double brackets pin generics. You use them for ambiguous calls.
- Next: Type System RFCs.