Wado

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:

  1. Tuple trait impls without arity explosion: Eq, Default, Inspect, Serialize for any tuple without writing separate impls for 0, 1, 2, … element tuples.
  2. 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 Inspect for 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, …:

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:

At monomorphization time the compiler:

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


Consequences

Positive

Negative

Out of Scope (Future Work)


See Also