Wado

WEP: Variant-Independent Types

Context

Wado's variant payload design (wep-2026-01-25-variant-payload-design.md) proposes that each variant case should be usable as an independent type:

variant Shape {
    Circle(f64),
    Rectangle([f64, f64]),
    Point,
}

fn process_circle(c: Shape::Circle) {
    println(`radius: {c.0}`);
}

This was listed as "Phase 3" with no detailed semantics. This WEP defines the full semantics, including the subtype relationship, method resolution, trait implementations, and operator overloading, and establishes that all dispatch is static — no virtual method tables or runtime dispatch is introduced.

Motivation

Rust's enums do not allow treating individual variants as types. This leads to:

TypeScript's discriminated unions treat each member as a standalone type. Wado can do the same while maintaining static dispatch.

Wasm GC Foundation

The SubtypeHierarchy representation already provides the necessary foundation:

(type $Shape          (struct (field $tag i32)))
(type $Shape::Circle  (sub $Shape (struct (field $tag i32) (field $payload f64))))
(type $Shape::Point   (sub $Shape (struct (field $tag i32))))

$Shape::Circle is already a Wasm GC subtype of $Shape. The upcast is zero-cost — the reference is used as-is with a wider type. Downcast uses ref.test / ref.cast.

For NullableRef variants (e.g., Option<T> with 2 cases, 1 unit):

Decision

1. Core Principle: Static Dispatch Only

The subtype relationship between variant cases and their parent variant is for data compatibility (upcast) only, not for behavioral polymorphism (virtual dispatch).

Concretely:

This is consistent with Wado's existing static dispatch model and with Wasm GC's type system, which provides subtype testing (ref.test) and casting (ref.cast) but not virtual method dispatch.

2. Variant Case as Type

Each variant case can be used as a type:

// As function parameter
fn area(c: Shape::Circle) -> f64 {
    return 3.14159 * c.0 * c.0;
}

// As variable type
let circle: Shape::Circle = Shape::Circle(5.0);

// As return type
fn make_circle(r: f64) -> Shape::Circle {
    return Shape::Circle(r);
}

For generic variants, the case type includes the type parameters:

let some: Option::<i32>::Some = Option::<i32>::Some(42);
let ok: Result::<i32, String>::Ok = Result::<i32, String>::Ok(42);

Type inference is encouraged over explicit turbofish:

let some = Option::Some(42);          // inferred as Option::<i32>::Some
let opt: Option<i32> = some;          // upcast

3. Subtype Relationship

Shape::Circle <: Shape — every case type is a subtype of its parent variant type.

Implicit coercion (upcast):

let circle: Shape::Circle = Shape::Circle(5.0);
let shape: Shape = circle;  // OK: implicit upcast

Explicit downcast via pattern matching:

let shape: Shape = get_shape();
if let Circle(r) = shape {
    // r: f64 — extracted payload
    // To get Shape::Circle, reconstruct or use a typed binding
}

No implicit downcast. The only way to go from Shape to Shape::Circle is pattern matching.

4. Method Resolution

Methods on the variant type

impl Shape {
    fn name(&self) -> String {
        match self {
            Circle(_) => "circle",
            Rectangle(_) => "rectangle",
            Point => "point",
        }
    }
}

Callable on Shape and on any case type (because case types are subtypes):

let circle = Shape::Circle(5.0);
circle.name();   // OK: Shape::Circle <: Shape, calls Shape::name

The call works by implicitly upcasting &Shape::Circle to &Shape. The method body receives &Shape and uses pattern matching internally. This is static dispatch — no vtable.

Methods on a case type

impl Shape::Circle {
    fn area(&self) -> f64 {
        return 3.14159 * self.0 * self.0;
    }
}

Only callable when the static type is Shape::Circle:

let circle = Shape::Circle(5.0);
circle.area();   // OK

let shape: Shape = circle;
// shape.area();  // ERROR: Shape has no method 'area'

To call case-specific methods through a variant reference, use pattern matching:

if let Circle(r) = shape {
    let c = Shape::Circle(r);
    c.area();
}

Lookup order for case type method calls

When calling a method on Shape::Circle:

  1. Look for methods defined on Shape::Circle (case-specific impl)
  2. Look for methods defined on Shape (parent variant impl, via upcast)

If both define the same method name, the case-specific method takes priority.

5. Trait Implementations

Trait impl for a case type

impl Eq for Shape::Circle {
    fn eq(&self, other: &Self) -> bool {
        return self.0 == other.0;
    }
}

This makes Shape::Circle satisfy the Eq bound. It does not make Shape satisfy Eq.

Trait impl for the variant type

impl Eq for Shape {
    fn eq(&self, other: &Self) -> bool {
        match [self, other] {
            [Circle(r1), Circle(r2)] => r1 == r2,
            [Point, Point] => true,
            _ => false,
        }
    }
}

This makes Shape satisfy the Eq bound.

Trait fallback from variant to case

If impl Eq for Shape exists but impl Eq for Shape::Circle does not, then Shape::Circle inherits Eq from Shape (the case value is upcasted to Shape for the method call).

If both exist, the case-specific impl takes priority when the static type is Shape::Circle.

// Resolution rules:
// 1. Does Shape::Circle have its own impl Eq?  → use it
// 2. Does Shape have impl Eq?                  → use it (via upcast)
// 3. Neither                                   → Eq not satisfied

This is analogous to newtype trait inheritance, but in the opposite direction (subtype inherits from supertype, not derived from base).

No automatic composition

Case-level trait impls do not automatically compose into a variant-level impl:

impl Eq for Shape::Circle { ... }
impl Eq for Shape::Point { ... }
// This does NOT mean Shape implements Eq
// You must write impl Eq for Shape { ... } explicitly

This avoids the need for dynamic dispatch. If case impls automatically composed into a variant impl, calling shape.eq(other) on a Shape reference would require runtime dispatch to determine which case's eq to call.

6. Operator Overloading

Operator traits work identically to regular traits:

impl Add for Shape::Circle {
    type Output = Shape::Circle;
    fn add(self, other: Shape::Circle) -> Shape::Circle {
        return Shape::Circle(self.0 + other.0);
    }
}

let c1 = Shape::Circle(3.0);
let c2 = Shape::Circle(4.0);
let c3 = c1 + c2;  // Shape::Circle(7.0) — static dispatch

This does not make Shape + Shape valid. To support Shape + Shape, write impl Add for Shape with pattern matching inside.

Mixing case types in operators is only possible if explicitly defined:

// c1 + c2 where c1: Shape::Circle, c2: Shape::Circle → OK (if impl Add for Shape::Circle)
// s1 + s2 where s1: Shape, s2: Shape → OK only if impl Add for Shape
// c1 + s1 where c1: Shape::Circle, s1: Shape → OK only if impl Add for Shape (c1 upcasted)

7. Interaction with Pattern Matching

When the parameter type is a case type, pattern matching is unnecessary:

fn circle_area(c: Shape::Circle) -> f64 {
    return 3.14159 * c.0 * c.0;  // Direct payload access
}

Pattern matching on a case type still works but is trivially exhaustive:

fn process(c: Shape::Circle) {
    match c {
        Circle(r) => println(`radius: {r}`),
        // No other arms needed — exhaustive
    }
}

8. NullableRef Variant Cases

For variants using the NullableRef representation (e.g., Option<T>):

Case Type Wasm Representation Values
Option::<T>::Some (ref $T) or (ref $box_T) non-null reference
Option::<T>::None unit type single value (null)

The None case type is effectively a unit type with one value. Implementing traits on it is allowed but rarely useful.

// Allowed but unusual
impl Display for Option::<i32>::None {
    fn display(&self) -> String {
        return "None";
    }
}

9. Generic Variant Case Types

For generic variants, case types carry the type parameters:

variant Result<T, E> {
    Ok(T),
    Err(E),
}

// Monomorphized case types
fn unwrap(r: Result::<i32, String>::Ok) -> i32 {
    return r.0;
}

Type parameter inference works when context provides the information:

let ok = Result::Ok(42);  // Result::<i32, ???>::Ok — E not determined
let ok: Result<i32, String> = Result::Ok(42);  // Full type known
Aspect Newtype (type T = U) Variant Case (V::Case)
Direction T derived from U V::Case <: V
Conversion Explicit as both directions Implicit upcast, pattern match downcast
Method inheritance Inherits all from base Inherits from parent variant
Own methods impl T { } impl V::Case { }
Trait inheritance All base traits inherited Parent variant traits as fallback
Runtime cost Zero (same representation) Zero (Wasm GC subtyping)
Aspect Trait objects (&dyn Trait) Variant case types
Dispatch Dynamic (vtable) Static
Type erasure Yes No
Open/closed Open (any implementor) Closed (fixed set of cases)
Use case Heterogeneous collections Type-safe variant handling

Implementation Strategy

Phase 1: Type System

Phase 2: Methods and Traits

Phase 3: Codegen

Phase 4: Payload Access

Consequences

Benefits

Trade-offs

Why Not Dynamic Dispatch?

One might expect that if Shape::Circle <: Shape, then calling a method through Shape should dispatch to the case's implementation. This would require either:

  1. Vtable: A function pointer table stored in each variant value, adding memory overhead and preventing Wasm GC optimizations
  2. br_on_cast chain: A series of runtime type tests, linear in the number of cases

Both options add runtime cost and complexity. More importantly, they would make Wado's variant system behave like single-inheritance OOP, which contradicts the language's design philosophy of explicit control flow and static dispatch.

Instead, Wado provides two explicit alternatives:

This keeps variant types as what they fundamentally are — tagged unions with typed members — rather than class hierarchies with virtual methods.

See Also