WEP: Variadic Type Parameters
Context
Wado's tuple type [T, U, V] is heterogeneous and fixed-arity. Today, writing a trait
implementation that covers tuples of any length requires per-arity boilerplate, much
like Rust's standard library does with procedural macros for arities up to 12. This is
the problem variadic type parameters solve.
Primary use cases driving this design:
- Tuple trait impls without arity explosion:
Eq,Default,Inspect,Serializefor any tuple without writing separate impls for 0, 1, 2, … element tuples. - Struct reflection: expose a struct's fields as a typed tuple at compile time so that
generic Wado code (rather than compiler magic) can implement
Inspectfor structs.
Background research: Research: Variadic Generics / Variadic Templates
Decision
1. Type Pack Declaration
A type parameter prefixed with .. declares a type pack — a sequence of zero or more
types that is fixed at each monomorphization site:
fn process<..T>(values: [..T]) { }
..T is a single parameter that stands for any number of type arguments. Scalar
parameters and packs may appear together, in any number (§6):
fn example<A, B, ..T>(a: A, b: B, rest: [..T]) { }
A method declares one the same way, alongside whatever its impl block
declares. The block's parameters and the method's own are separate spaces, so
the method's pack takes only the type arguments the call spells for the method:
impl<T> Box<T> {
fn spread<..U>(&self, u: [..U]) -> i32 { }
}
The .. prefix was chosen for consistency with Wado's existing .. semantics: struct
update (..p), rest patterns ([a, ..]), and value spread ([..a, ..b]). All uses of
.. in Wado carry the meaning "expand / spread a sequence."
2. Type Pack in Type Position
A type pack ..T may appear inside [...] to produce a tuple type:
[..T] // a tuple whose element types are the pack T
[i32, ..T] // an i32 followed by the elements of T
[..T, ..U] // concatenation of two packs (see §6 Multi-Pack)
This mirrors tuple literal syntax and tuple destructuring syntax, making ..T in a type
context visually consistent with its value-context meaning.
3. Tuple Type Declaration in Prelude
To establish a clear owner for the tuple type family, the prelude declares:
pub type [...T];
This declaration names the module that owns tuple types as core:prelude. Without this,
orphan rules (see §5) cannot determine whether a variadic impl is in the "right" crate.
The declaration itself generates no code; it is a type-system anchor.
4. Bounds on Type Packs
A pack parameter may carry trait bounds using the same ..T: Trait syntax as scalar bounds:
fn inspect_all<..T: Inspect>(values: [..T]) -> List<String> { ... }
impl<..T: Eq> Eq for [..T] { ... }
impl<..T: Default> Default for [..T] { ... }
The bound ..T: Trait means "every type in the pack T implements Trait." This is checked
at monomorphization: when T is instantiated to [i32, String], the compiler verifies
that i32: Eq and String: Eq.
Multiple bounds are written with +:
impl<..T: Ord + Eq> Sortable for [..T] { ... }
5. Coherence Rules
Two rules govern impl overlap for variadic impls:
Rule 1 — Non-variadic wins: When both a non-variadic impl and a variadic impl could apply to a concrete type, the non-variadic impl takes priority. This allows a concrete specialization to override the general tuple impl:
impl Eq for [i32, i32] { ... } // concrete — wins over ↓
impl<..T: Eq> Eq for [..T] { ... } // variadic — fallback
Rule 2 — Bounds separate variadic impls as they separate head impls: two variadic impls of one trait at different bounds are both kept. A tuple takes the one whose bounds it meets, and a tuple meeting both is an ambiguity reported at the call. Only the same impl written twice is rejected where it is written:
impl<..T: Eq> Tag for [..T] { ... } // OK
impl<..T: Ord> Tag for [..T] { ... } // OK; `[1, 2].tag()` meets both and reports
impl<..T: Eq> Tag for [..T] { ... } // ERROR: duplicate impl
Rule 2 first rejected the second impl at definition time, whatever its bounds, because
a pack's bounds were then checked only at monomorphization. Selection now reads them
(Eligibility), so a variadic impl follows the same rule
as impl<T: Eq> Tag for Box_<T> beside impl<T: Ord> Tag for Box_<T>.
These two rules together mirror the priority model used in WEP-2026-02-10 for tuple enumeration.
Rule 1 needs no priority table. A concrete tuple impl is a concrete instantiation
impl — the same shape as impl Tag for List<u8> — so it defines the very function a
[i32, i32] receiver calls, and the template is not instantiated onto a name an impl
already occupies. The specific impl wins because it got there first.
Rule 1 is scoped to one trait in both directions. It ranks a trait's own impls against
each other and says nothing about another trait's, so a foreign blanket impl<T> A for T
must not outrank a local impl<..T> B for [..T]. And it needs the trait's signature to
make the specific and general methods interchangeable, so it does not extend to inherent
impls: impl<T> Box_<T> { fn a() -> String } beside impl Box_<i32> { fn a() -> i32 }
is a duplicate definition, rejected as in Rust, rather than a specialization that would
relink a generic caller to a differently-typed function.
Rule 2 is a definition-time check: two variadic impls of one trait apply at every arity,
and a pack's bounds are resolved only at monomorphization, so nothing could separate them
at selection time. The trait's own arguments do separate them — Conv<i32> and
Conv<String> are implementations of different things.
A variadic impl target must be the bare [..T]. [i32, ..T] is a legal type (§2) but
not yet a legal impl target.
Orphan rules apply normally: a variadic impl impl<..T> Trait for [..T] is only legal
if either Trait or the tuple type family (type [...T] from core:prelude) is owned
by the current crate. Because core:prelude owns tuples, the standard library can write
variadic tuple impls; downstream crates may write variadic impls only for their own traits.
These two rules rank impls whose target is the tuple family. A trait's value blankets are ranked separately. A value blanket targets a bare type parameter and is selected by its bounds. Two of them overlapping is a use-site error, not a definition-time one, for two reasons: a trait is meant to carry several, and an open world cannot decide whether two bounds can both hold. For the full order, and where these two rules sit in it, see Trait Resolution.
6. Multi-Pack
A generic parameter list may declare more than one pack. Each is settled from the argument that carries it alone, so nothing has to find a boundary the source never wrote. The primary use case is concatenation:
fn concat<..A, ..B>(a: [..A], b: [..B]) -> [..A, ..B] { ... }
A tuple holding two packs ([..A, ..B]) settles neither, because every split of a
concrete tuple satisfies it. The rule therefore keys on settling, not on where the tuple
is written: such a tuple is legal anywhere, and each pack it names must be settled by
something else — another parameter, a turbofish, or an annotation. Once they are, the
tuple is checked against the arity they fix, so a surplus element is a type mismatch
rather than something a split silently absorbs.
fn joined<..A, ..B>(split: [[..A], [..B]], both: [..A, ..B]) -> i32 { ... }
fn middle<..Pre, K, ..Post>(t: [..Pre, K, ..Post]) -> i32 { ... }
joined([[1], [true]], [1, true]); // `split` settles both
middle::<[i32], String, [bool]>([1, "mid", true]); // the turbofish settles them
A pack nothing settles is reported at the use site, as an uninferred type parameter. That
is where the answer would come from, and it is the only place that knows whether a
turbofish supplied one. To settle both packs from one value, each takes a tuple of its own
([[..A], [..B]]).
A pack binds from a written type argument as a scalar type parameter does, and not only from the positions a value offers. That is what lets a turbofish or an annotation settle one.
Where such a tuple is produced, its ends still place elements. The elements ahead of the
first pack and behind the last keep their positions whatever the packs expand to, so
[X, ..A, ..B, Y] places X and Y, and the packs are left to the arguments that carry
them alone.
The same holds for a turbofish, which is flat: f::<i32, bool>() names a boundary only
because one pack absorbs everything past the scalars. With two packs each is spelled as
its own tuple (concat::<[i32], [bool, String]>(…)); any other spelling is an error,
one argument per pack included, since <i32, String> and <[i32, String], []> split one
list two ways.
An element between two packs, as in [..A, M, ..B], is determined once the packs are
bound, but binding them does not feed the constraint again. M is reported uninferred,
and a turbofish names it.
Inside the body that declares them, packs are rigid, as scalar parameters are. A value
matches a pack-carrying tuple by layout: the same fixed positions, and the same packs in
the same order. So [..B, ..A] does not accept an [..A, ..B] value, and [i32, ..A]
does not accept an [..A] one. Naming the same packs is not enough.
zip and interleave transpose their operands, so their rows must be equally long. Two
distinct packs are never known to be. Both are therefore out of scope for this WEP, and
the compiler rejects such a zip where it is written.
Rows that do share a layout transpose slot by slot, and a pack slot yields a pack again.
[[..A], [..A]].zip() is a tuple of one pack whose element k is [A_k, A_k]. A fixed
element in every row keeps its own column, ahead of the pack: [[i32, ..A], [String, ..A]]
transposes to [i32, String] followed by that pack. The result names the pack rather than
the positions it expands to, so a let may hold the transpose. Expanding it before the
pack is bound would fix an arity it has not been given.
7. Compile-Time Tuple Enumeration with Packs
The existing compile-time tuple enumeration (WEP-2026-02-10) works unchanged when the
tuple type is [..T] — once the pack is instantiated to a concrete tuple type, the
elaborator unrolls the for let v of tuple loop as usual:
fn inspect_all<..T: Inspect>(values: [..T]) -> List<String> {
let mut parts: List<String> = [];
for let v of values {
parts.push(v.inspect());
}
return parts;
}
No changes to the existing for let v of tuple semantics are required. The only new
requirement is that the monomorphizer recognizes [..T] as a concrete tuple type once
T is substituted.
8. Expansion Syntax
Two syntactic forms exist for constructing a new tuple from a type pack:
8a. Type Pack Expansion: [..T::method()]
When there is no source value and construction is driven purely by the type pack, use
..T::method() inside a tuple literal:
impl<..T: Default> Default for [..T] {
fn default() -> [..T] {
return [..T::default()];
}
}
[..T::default()] expands at monomorphization to [T_0::default(), T_1::default(), ...]
— one call per type in the pack.
8b. Value-Transform Collection: [for let v of tuple { expr }]
When a source tuple exists and each element is transformed to produce the result tuple,
wrap a for let v of tuple expression in [...]:
impl<..T: Doubled> Doubled for [..T] {
fn doubled(&self) -> [..T] {
return [for let v of *self { v.doubled() }];
}
}
The braces hold one expression — the element's value — not a statement block: a comprehension produces a tuple, so every element has a value.
At monomorphization, the compiler unrolls the loop and collects each result expression
into the corresponding position of a new tuple literal. A body that yields the element
unchanged reproduces the source shape; anything else maps the pack through the body's
type, so a body interpolating the element into a template yields a walkable
[..String].
The [for let [i, v] of tuple.enumerate() { expr }] form (with index binding) is also
valid.
Disambiguation with arrays: [for let v of x { expr }] produces a tuple when x
has a tuple type (known at monomorphization time) and an array when x has type
List<E> (runtime iteration). The two paths are resolved by the type of the iterable.
Break/continue inside [for ... { }] are compile errors, consistent with WEP-2026-02-10.
9. Value Spread
A type pack value a: [..T] can be spread into a tuple literal using ..a:
fn prepend<H, ..T>(head: H, tail: [..T]) -> [H, ..T] {
return [head, ..tail];
}
[..a, ..b] concatenates two pack values into one tuple, consistent with existing struct
update spread (..p) and the general "spread a sequence" meaning of ...
10. Reflect: Struct Metadata as a Typed Tuple
ReflectStruct is a compiler-synthesized, sealed language feature — it cannot be implemented
in user code. It exposes a struct's field types and members at compile time via a trait, and
its members are reached only as ReflectStruct::<T>::members() (see
Reflect Derivation):
#[compiler_item("reflect_struct")]
pub trait ReflectStruct: Reflect { // `Reflect` carries `type_name()`
type FieldTypes;
type Members;
fn members() -> Self::Members;
}
The compiler automatically synthesizes impl ReflectStruct for S for every struct S. For a
struct with fields f_0: F_0, f_1: F_1, …:
type FieldTypes = [F_0, F_1, …]— the payload pack, bound to place per-field boundstype Members = [StructField<S, F_0>, StructField<S, F_1>, …]members()returns oneStructFieldper field, carrying its name, attributes and aget(&self, v: &S) -> F_kvalue accessorReflect::<S>::type_name()returns the struct name as a string (the identity root, reached throughReflectStruct's supertrait)
Why compiler-synthesized
ReflectStruct returns Self::Members, which is a concrete tuple type specific to each
struct. Without any, the compiler must generate the implementation at compile time for
each struct individually.
Why only in monomorphized contexts
ReflectStruct::<T>::members() and Reflect::<T>::type_name() are only callable when T
is a concrete struct type, because the implementation is generated per struct, not for a
generic T.
11. Pack Binding — Type Pack Pattern Matching
A bound may bind a type pack from an associated type:
impl<T: ReflectStruct<FieldTypes = [..F]>, ..F: Inspect> Inspect for T {
fn inspect(&self) -> String {
let mut parts: List<String> = [];
for let f of ReflectStruct::<T>::members() {
parts.push(`${f.name()}: ${f.get(self).inspect()}`);
}
return `${Reflect::<T>::type_name()} \{ ${parts.join(", ")} \}`;
}
}
T: ReflectStruct<FieldTypes = [..F]> constrains T to be any type that implements ReflectStruct
with a FieldTypes associated type that matches the pack F. The compiler extracts F from
the concrete FieldTypes type at monomorphization. This is the mechanism that lets the
struct-inspect implementation be written entirely in Wado.
Type Checking Model
Variadic generics follow the same C++ template model used by compile-time tuple enumeration (WEP-2026-02-10): type-checking occurs at monomorphization time, not at definition time.
At definition time the compiler:
- Parses and stores pack declarations and bounds
- Does not verify that pack element types satisfy method calls in the body
- Does verify structural well-formedness (e.g., that
..Tis used in a valid position)
At monomorphization time the compiler:
- Substitutes concrete types for the pack
- Unrolls
for let v of tupleand[for let v of tuple { }]loops - Type-checks each unrolled block independently
- Checks trait bounds on each concrete element type
Error messages must include: the call site where the concrete pack was determined, the specific element index and type that failed, and the location in the body where the error occurred.
Implementation Plan
- [x] The
[for let v of tuple { expr }]/[for let [i, v] of tuple.enumerate() { expr }]construction form, over a pack-typed tuple - [ ] The same form over a concrete tuple: its elements have unrelated types,
so the body needs resolving once per element (the per-element overlays the
concrete
for-ofalready keeps), not once against a pack element - [x]
.enumerate()over a variadicfor-of - [x] The
.enumerate()index as a tuple subscript, for reads and writes alike - [ ] Variadic impl targets other than the bare
[..T]— fixed elements ([i32, ..T]) or under a reference (&[..T]): rejected for now. Selection, pack binding, and template naming all ignore the fixed elements, and a pack under a reference never reaches the impl's type-param scope - [x] Pack binding: parse
T: Trait<Assoc = [..F]>and extractF - [x] More than one pack per generic parameter list (§6)
- [ ] Error messages: show call site, element index, and body location
- [ ] Standard library: add a variadic impl for
Default
Consequences
Positive
- Tuple trait impls are written once for all arities — no per-arity boilerplate
- Struct
Inspectmoves from compiler magic to ordinary Wado code - Expansion syntax (
..T::method(),[for let v { }]) is minimal and consistent with existing Wado conventions - Coherence model is simple: two rules, no full trait solver rewrite
- Zero runtime cost: all expansion happens at compile time via monomorphization
Negative
- Monomorphization-time errors are harder to attribute than definition-time errors; error message quality requires careful engineering
- Each unique pack instantiation generates separate code (binary size growth for large tuples or many distinct instantiations)
ReflectStructis compiler-synthesized; it cannot be manually implemented or overridden by user code
Out of Scope (Future Work)
- Pack indexing:
T[0]to extract the first type from a pack — useful but complex. A literal index landing on a pack (t.0wheret: [..T]) is rejected: neither the bound nor the per-position type is decidable before the pack expands. A scalar ahead of the pack ([A, ..T].0) keeps its fixed position, and the.enumerate()subscript stays the way to reach an element. - Definition-time trait bound checking: checking
..T: Traitat definition time rather than at monomorphization (requires a richer trait solver) - Fold operations:
(v op ... op init)C++-style fold expressions - Complex multi-pack operations: zip, interleave of packs with different lengths
- Variadic closures:
|..args: ..T|— deferred until closure monomorphization is well understood
