---
url: /strict-typing/type-packs-and-variadics.md
description: Type variable arguments, multiple returns, and forwarding wrappers end to end.
---

# Type Packs and Variadics

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

## Learning objectives

* You type variadic defs with `...: T` and 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

* [Generics](./generics.md)
* [Annotations and Inference](./annotations-and-inference.md)
* [Functions Advanced](../luau-basics/functions-advanced.md)

## 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](https://rfcs.luau.org/syntax-typed-variadics.html). The [type checking](https://create.roblox.com/docs/en-us/luau/type-checking) guide shows `...: number` rejecting a string arg and `(...number)` as the alias form. The [syntax tour](https://luau.org/syntax/) 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](https://rfcs.luau.org/syntax-type-alias-type-packs.html). The [luau.org types](https://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](https://create.roblox.com/docs/en-us/luau/type-checking) guide shows the multi-return paren form. The [syntax tour](https://luau.org/syntax/) 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](https://luau.org/syntax/) defines unions, intersections, and `T?` optionals in the same type family. The [type checking](https://create.roblox.com/docs/en-us/luau/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) -> number
```

This block errors. Alias packs use `(...number)`, not `...: number`. You fix the spelling.

```luau
--!strict
export type Good = (...number) -> number
```

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

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

This 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

1. Define a number-sum variadic and reject a string.
2. Store the same shape in an alias with `(...T)`.
3. Return two values and bind one versus two locals.
4. Forward a string pack through a logger wrapper.
5. Model two call shapes with an optional.

::: details 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.
:::

::: details 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.
:::

::: details 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.
:::

::: details 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.
:::

::: details 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](./solver-and-migration.md).
