---
url: /rfcs/generics-and-packs.md
description: >-
  Named arguments, typed variadics, pack aliases, defaults, and explicit
  instantiation explained.
---

# Generics and Packs RFCs

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

## 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

* [Functions Advanced](../luau-basics/functions-advanced.md)
* [Generics](../strict-typing/generics.md)
* [Type Packs and Variadics](../strict-typing/type-packs-and-variadics.md)
* [How to read RFC status](./implemented.md)

## 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](https://rfcs.luau.org/syntax-named-function-type-args.html) 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 10
```

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

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

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

## 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](https://rfcs.luau.org/syntax-typed-variadics.html) 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
10
```

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

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

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

## 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](https://rfcs.luau.org/syntax-type-alias-type-packs.html) 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
true
```

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

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

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

## 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](https://rfcs.luau.org/syntax-default-type-alias-type-parameters.html) 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
sword
```

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

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

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

## 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](https://rfcs.luau.org/explicit-type-parameter-instantiation.html) 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
10
```

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

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

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

## 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

1. Name the arguments of a two-parameter callback alias.
2. Write a typed variadic max for numbers.
3. Forward a pack through a wrapper alias.
4. Add a default to an alias and use both arities.
5. Pin a generic call that receives nil.

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

You label both positions. The implementation matches by type.
:::

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

You gather any count of numbers. You track the largest.
:::

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

You declare the pack once. You forward it into the method. Instantiation with two types checks both.
:::

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

You default the label to string. The short use takes the default. The long use overrides it.
:::

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

You 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](./type-system-rfcs.md).
