Appearance
Type Packs and Variadics
Written: 2026-10-14
Learning objectives
- You type variadic defs with
...: Tand alias them with(...T). - You store packs inside aliases.
- You declare multiple returns precisely.
- You forward variadics without losing types.
- You model overload-like choice with unions and optionals.
Prerequisites
Definitions take ...: T, aliases take (...T)
A function body collects many values. A type alias names that collector. Each side uses its own syntax.
The precise rule is positional. In a function definition you write ...: number as the last param. In a function type you write (...number) in the same slot. This split is defined in typed variadics. The type checking guide shows ...: number rejecting a string arg and (...number) as the alias form. The syntax tour shows function and table type neighbors of the same syntax family.
Variadics stay last. No param follows them. Named variadics beyond the type itself are not part of this pattern.
You annotate implementations with ...: T. You name callback and handler shapes with (...T). Readers recognize each form at once.
Put this Script in ServerScriptService.
luau
--!strict
local function total(...: number): number
local args = table.pack(...)
local sum = 0
for i = 1, args.n do
sum += args[i]
end
return sum
end
local got = total(2, 4, 6)This block checks clean. You infer got as number. The definition accepts any count of numbers. A string arg errors because it cannot convert to number.
Put this Script in ServerScriptService.
luau
--!strict
export type Spray = (...number) -> number
local function total(...: number): number
local args = table.pack(...)
local sum = 0
for i = 1, args.n do
sum += args[i]
end
return sum
end
local fn: Spray = total
local got = fn(1, 2)This block checks clean. You infer got as number. The alias uses (...number) where the definition used ...: number. Assignment proves they describe the same shape.
Packs live inside aliases
Some helpers forward any count of one type, and some signals carry mixed types. Aliases hold those tails.
The precise rule is pack params and pack tails. A generic pack like <T...> plus ...: T... forwards uniform or mixed tails. A return like (number, string...) names a head plus a tail. Pack semantics inside aliases are defined in type alias packs. The luau.org types intro frames packs as part of gradual checking.
Complex multi-pack aliases produce long errors. Simple one-pack wrappers read best. You expand a complex pack only when reuse pays.
Event buses, loaders, and loggers forward exact tails. Callers keep autocomplete through the wrapper.
Put this ModuleScript in ReplicatedStorage.
luau
--!strict
export type Run = (...string) -> ()
local function shout(...: string)
local args = table.pack(...)
for i = 1, args.n do
print(args[i])
end
end
local fn: Run = shout
fn("hi", "there")This block checks clean. You infer fn as (...string) -> (). The alias stores the string pack. Assignment proves shout matches. A number arg to fn errors.
Put this Script in ServerScriptService.
luau
--!strict
local function forward<T...>(fn: (T...) -> (), ...: T...)
fn(...)
end
local function hello(a: string, b: string)
print(a .. b)
end
forward(hello, "Sky", "Fox")This block checks clean. You infer the pack as (string, string) for this call. The wrapper takes the same tail it passes. Types survive the forward without casts.
Multiple returns are packs with names in order
First result matters most. Later results add detail. The type lists them in the same order.
The precise rule is parens for more than one. A single return uses : T. Multiple returns use : (A, B). No return uses : (). The type checking guide shows the multi-return paren form. The syntax tour shows the same return-position syntax.
Every return path must send the same count and types. Extra parens around a call collapse the tail to one value. A cast on a multi-call keeps only the first value.
Parsers return value plus error detail. Lookups return value plus found flag. Callers bind both or take only the head on purpose.
Put this Script in ServerScriptService.
luau
--!strict
local function cut(full: string): (string, string)
local at = string.find(full, " ")
if at == nil then
return full, ""
end
return string.sub(full, 1, at - 1), string.sub(full, at + 1)
end
local first, last = cut("Sky Fox")This block checks clean. You infer first as string and last as string. Both paths return two strings. Return one value and the checker reports an arity mismatch.
Put this Script in ServerScriptService.
luau
--!strict
local function find(raw: string): (boolean, string)
if raw == "" then
return false, "empty"
end
return true, raw
end
local ok, value = find("gem")
local firstOnly = find("gem")This block checks clean. You infer ok as boolean, value as string, and firstOnly as boolean. Binding one local keeps only the head. Binding two keeps the full pack.
Forwarding wrappers keep the pack intact
A logger, timer, or gate should not weaken the call it wraps. It adds behavior while the signature stays the same.
Forward with the same tail. A wrapper repeats the inner ...: T or <T...> tail and calls fn(...) positionally. You bind multi-returns before re-passing them. You never cast a pack inside the forwarding call because casts truncate.
Packing with table.pack plus n preserves trailing nils. Plain {...} can lose them. Nested functions cannot see the outer ..., so you pack first and close over the table.
Logging and permission wrappers add behavior. Signatures and arity stay exact.
Put this Script in ServerScriptService.
luau
--!strict
local function sum(...: number): number
local args = table.pack(...)
local total = 0
for i = 1, args.n do
total += args[i]
end
return total
end
local function loggedSum(...: number): number
print("sum called")
return sum(...)
end
local got = loggedSum(3, 4, 5)This block checks clean. You infer got as number. The wrapper repeats ...: number and forwards ... untouched. A string arg errors at both the wrapper and the worker.
Overload-like choice uses unions and optionals
One function covers a few shapes. A greeter takes a name alone or a name plus a title, and a loader takes an id or a record.
The precise rule is no overload syntax. Luau provides no fake multi-signature block. You model choice with optional params, union params, and union returns. The syntax tour defines unions, intersections, and T? optionals in the same type family. The type checking guide shows union aliases for the same purpose.
Unions accept the listed shapes and nothing else. A caller needing a brand-new shape needs a new union member. Error messages name the failed union, not an overload name.
One body handles every advertised shape. Callers read the union instead of guessing which overload exists.
Put this Script in ServerScriptService.
luau
--!strict
local function greet(name: string, title: string?): string
if title ~= nil then
return title .. " " .. name
end
return name
end
local a = greet("Mara")
local b = greet("Mara", "Captain")This block checks clean. You infer both as string. The optional second param models two call shapes with one body. A number title errors because it matches neither shape.
Put this Script in ServerScriptService.
luau
--!strict
local function load(target: string | number): string
if typeof(target) == "number" then
return "id " .. tostring(target)
end
return target
end
local a = load(7)
local b = load("gem")This block checks clean. You infer both as string. The union models id-or-name with one implementation. Branches refine each member. A boolean arg errors as outside the union.
Common mistakes
Wrong: you use definition syntax inside an alias.
Put this Script in ServerScriptService.
luau
--!strict
export type Bad = (...: number) -> numberThis block errors. Alias packs use (...number), not ...: number. You fix the spelling.
luau
--!strict
export type Good = (...number) -> numberThis fix checks clean. The alias now names a number tail.
Wrong: you place a param after the variadic.
Put this Script in ServerScriptService.
luau
--!strict
local function bad(...: number, last: number): number
return last
endThis block errors. Variadics stay last. You fix it by moving the fixed param first.
luau
--!strict
local function good(last: number, ...: number): number
return last
endThis fix checks clean. You infer good as (number, ...number) -> number.
Wrong: you invent overload blocks.
Put this Script in ServerScriptService.
luau
--!strict
local function greet(name: string): string
return name
end
local a = greet("Mara", "Captain")This block errors. Extra args do not create an overload. You fix it with an optional.
luau
--!strict
local function greet(name: string, title: string?): string
if title ~= nil then
return title .. " " .. name
end
return name
end
local a = greet("Mara", "Captain")This fix checks clean. You infer a as string. One body covers both shapes.
Exercises
- Define a number-sum variadic and reject a string.
- Store the same shape in an alias with
(...T). - Return two values and bind one versus two locals.
- Forward a string pack through a logger wrapper.
- Model two call shapes with an optional.
Solution 1
Put this Script in ServerScriptService.
luau
--!strict
local function sum(...: number): number
local args = table.pack(...)
local total = 0
for i = 1, args.n do
total += args[i]
end
return total
end
local got = sum(4, 5)This block checks clean. You infer got as number. Call sum(4, "x") and the checker rejects the string member.
Solution 2
Put this Script in ServerScriptService.
luau
--!strict
export type Each = (...string) -> ()
local function say(...: string)
end
local fn: Each = say
fn("a", "b")This block checks clean. The alias and the definition describe the same tail with different spellings. A number arg errors.
Solution 3
Put this Script in ServerScriptService.
luau
--!strict
local function divmod(a: number, b: number): (number, number)
return a // b, a % b
end
local q, r = divmod(17, 5)
local only = divmod(17, 5)This block checks clean. You infer q as number, r as number, and only as number. Two bindings keep the pack. One binding keeps the head.
Solution 4
Put this Script in ServerScriptService.
luau
--!strict
local function shout(...: string): ()
local args = table.pack(...)
for i = 1, args.n do
print(args[i])
end
end
local function logged(...: string): ()
print("shout")
shout(...)
end
logged("hi", "fox")This block checks clean. The logger repeats the tail and forwards it. Types and arity survive the wrap.
Solution 5
Put this Script in ServerScriptService.
luau
--!strict
local function award(name: string, count: number?): string
if count ~= nil then
return name .. tostring(count)
end
return name
end
local a = award("gem")
local b = award("gem", 3)This block checks clean. You infer both as string. The optional models one-or-two args without overload syntax.
Summary
- Definitions use
...: T. Aliases use(...T). - Generic packs forward exact tails.
- Multi-returns use paren lists with fixed arity.
- Wrappers repeat the tail and forward positionally.
- Unions and optionals model overload-like choice.
- Next: Solver and Migration.