Wado

WEP: Resource Inheritance and Downcast (resource extends)

Context

Wado's resource declares an opaque handle to a host-managed object. Today every resource is flat: it has methods, but no relation to any other resource type. This matches the Component Model (CM), whose canonical ABI defines resources as flat — there is no inheritance, no subtyping between resource types.

What we want to solve

WebIDL bindings. Browser APIs are defined as deep single-inheritance hierarchies (EventTarget → Node → Element → HTMLElement → HTMLInputElement). A single JS object simultaneously satisfies every type in its prototype chain. Without language-level inheritance, the binding generator has to either (a) duplicate every parent method on every child, (b) force the user to cast at every level (the wasm-bindgen dyn_into pattern), or (c) lose type information entirely.

WIT does not have inheritance and is not a target for this feature. Pure-Wado user code that happens to fit the model gets it for free, but is not the design driver.

Why this is hard

CM has no inheritance. Whatever Wado decides at the language level has to be lowered to a flat-resource model at the CM boundary, or use a non-CM-canonical representation (Wasm GC's externref). The choice of representation cascades into every other design decision — upcast cost, downcast mechanism, method dispatch, ABI shape, host contract — so this WEP starts there.

resource today maps to i32 in CM-LM mode and to externref in CM-GC mode (per GC in Components). Inheritance forces us to be explicit about which one is the model, because i32 handle tables and externref answer "is this handle also a parent?" very differently.

Decision

Guiding principle

Every design choice in this WEP picks the strictest sound default and leaves room to relax based on real-world usage. Loosening a rule later (accepting a previously rejected program) is a non-breaking change; tightening would break existing code, so the strict end is the only safe starting point. This applies uniformly to subtyping variance, method resolution, downcast targets, trait/inherent collisions, and any other point of friction. Individual rules do not restate the principle.

Representation: keep both, pilot externref through Tide

Wado will support two resource representations for the foreseeable future:

Representation Backing Used by
i32 handle CM canonical handle table per type WIT-derived resources (WASI, etc.)
externref Wasm GC reference to a host object WebIDL-derived resources (Tide)

Reasoning:

This makes Tide the right pilot for externref-backed resources. The blast radius is contained: if externref proves wrong, WIT/WASI is untouched.

resource extends is gated on externref backing (v1)

In v1, resource X extends Y { ... } is permitted only when X and Y are both externref-backed.

The reason is mechanical, not philosophical: with externref, an HTMLInputElement value and the same value typed as Element are bit-identical wasm values, differing only in Wado's static type witness. Upcast is a no-op; downcast is a host-side instanceof check on a single ref. With i32 handles, an HTMLInputElement handle (index into one table) is not the same i32 as the corresponding Element handle (index into another table), so every cast becomes a host call that mints a new handle. That cost matters in tight loops and is not a problem we want to take on right now.

A future WEP can extend extends to i32-backed resources if we find a lowering we are happy with (e.g., shared handle tables across an extends family). This WEP does not preclude that.

How the representation is chosen for a given resource

Backing is always declared explicitly on the resource, and structurally verified by the compiler. There is no namespace-based inference, no default-by-omission for resources that participate in CM bindings.

Concretely:

Why mandatory + structural over namespace inference

Considered alternatives:

  1. Infer from namespace (web:* → externref, wasi:* → i32).
    • Pros: zero boilerplate, generators always get the right form.
    • Cons: hidden rule baked into the compiler, hard to extend to new namespaces (node:*, vendor-specific), silent surprise if a user picks a web:* URL by accident.
  2. Mandatory explicit attribute, no validation.
    • Pros: every declaration is grep-able; no magic.
    • Cons: a generator that forgets the attribute or picks the wrong value silently mismatches the host glue at runtime.
  3. Mandatory explicit attribute + structural validation (chosen).
    • Pros: every declaration is self-describing; cross-declaration consistency is enforced by the compiler, so "broken state compiles" cannot happen — extends mismatches are rejected, and declared backing is the single source of truth.
    • Cons: more boilerplate per declaration. In practice, ~all #[cm(...)]-bearing resources are emitted by wado-from-idl, so the cost falls on one tool, not on humans.

Syntax

pub resource Child extends Parent { ... }

The extends Parent clause slots between the resource name (and any generic parameter list) and the body. It is optional; resources without extends behave as today.

#[cm("web:dom#EventTarget", type="extern-ref")]
pub resource EventTarget { ... }

#[cm("web:dom#Node", type="extern-ref")]
pub resource Node extends EventTarget { ... }

#[cm("web:dom#Element", type="extern-ref")]
pub resource Element extends Node { ... }

Rules:

extends does not appear in any other position. There is no extends clause on struct, trait, enum, variant, flags, effect, or function declarations in this WEP. (A separate WEP could later introduce trait supertraits with a different syntax, e.g., :.)

No cross-representation conversion in v1

An externref-backed resource and an i32-backed resource are distinct types from Wado's perspective, even if they happen to point to the same host concept. There is no implicit conversion, and no as-cast, between them in v1.

In practice this is not a constraint: WebIDL bindings live in web:* modules and do not appear in WIT signatures, and vice versa. As CM-GC matures (per GC in Components), more types migrate to externref-style representations on their own; we expect the moment "convert i32 to externref" becomes urgent to never quite arrive.

Subtyping rules

Given resource Child extends Parent, the relation Child <: Parent is induced. The relation is reflexive (T <: T), transitive (A <: B and B <: CA <: C), and antisymmetric (no cycles, enforced syntactically). Resources unrelated by extends are incomparable.

The variance rules below are picked for soundness — every position where a write can re-establish the underlying value at a different concrete type is invariant. This is strictly stronger than Rust's defaults and rules out Java-array-style breakage.

Reference types

Given Child <: Parent:

Type Subtyping Justification
&Child &Child <: &Parent Read-only view; every method available on &Parent is available on &Child.
&mut Child invariant in the resource type A &mut Parent permits writing back any Parent (e.g., *r = other_parent); allowing &mut Child <: &mut Parent would let an arbitrary Parent be assigned where Child is required.

externref-backed resource handles have value semantics, so the common case is plain value passing — implicit upcast happens at the call site:

fn read_node(n: Node) -> u16 { return n.node_type(); }
let el: Element = ...;
read_node(el);               // OK: Element flows where Node is expected

The &T covariance rule applies when references are used, but & on resources is rare outside method receivers (&self), so the table entry is more formal than practical. &mut T invariance is the technical guard against *r = parent_value installing a non-Child value through a child reference; with &mut self not appearing on resources in this WEP's scope (see the Downcast sidebar), the rule rarely surfaces in resource code but remains in force for any &mut T slot.

Function types

fn(A) -> B (with or without with effects) is contravariant in A and covariant in B. Effects do not interact with subtyping for resources; they follow their own rules.

let f: fn(Element) -> i32 = ...;
let g: fn(Node) -> i32 = f;        // ERROR: contravariance — f might not accept arbitrary Nodes
let h: fn(Element) -> i32 = ...;   // OK
let k: fn(Element) -> Element = ...;
let m: fn(Element) -> Node = k;    // OK: Element <: Node in the result

The contravariant argument rule is what makes a generic event listener like fn(Event) accept callbacks that handle the most general event type, while still rejecting callbacks that demand a more specific input than the listener might receive.

Container and aggregate types

All composite types that store a resource value, struct field, or container slot are invariant in the contained resource type:

Type Variance in T
List<T> invariant
Option<T> invariant
TreeMap<K, V> invariant in both
[T, U, ...] (tuple) invariant in each element
struct { f: T } invariant in T (field assignment writes back)

The conservative invariance is forced by the existence of &mut access into the container — arr[i] = other and m.f = other must not be able to install an unrelated subtype.

Read-only views: covariant exception

A handful of types are "produce-only" with respect to their type parameter: there is no API that takes a &mut Self to write a T back. For those, covariance is sound:

Type Variance in T Reason
Future<T> covariant The only consumer-side API is read() which yields T. There is no set on the read end; writes go through FutureWritable<T>.
FutureWritable<T> contravariant Symmetric: writes into T, never reads.
Stream<T> covariant Same shape as Future<T>, repeated.
StreamWritable<T> contravariant Same as FutureWritable<T>.

These exceptions are tied to specific stdlib types whose API surface the compiler knows. They do not generalize to user-defined generics. Every user-defined generic — struct MyBox<T>, resource MyContainer<T>, variant MyEither<L, R>, etc. — is invariant in each of its type parameters, regardless of how the parameter is used inside the body. There is no variance annotation, and no auto-variance inference.

Concretely, given Element extends Node:

struct Box<T> { value: T }

let el: Element = ...;
let el_box: Box<Element> = Box { value: el };
let node_box: Box<Node> = el_box;          // ERROR: Box<Element> ≮: Box<Node>
let node_box: Box<Node> = Box { value: el_box.value };  // OK: upcast at field site

In practice variance only constrains user code when T is itself a resource that participates in an extends hierarchy. For non-resource T (primitives, unrelated structs, etc.) there is no subtyping to propagate, so the rule has no observable effect.

Where coercion fires

Implicit upcast is inserted at:

Implicit upcast does not fire at:

Pattern matching and match

A match on a value of type Parent cannot match the concrete Child arm by structure alone — that's a downcast, not subtyping (covered in a later section). Pattern matching against an extends-related type requires explicit downcast::<Child>() first.

Method resolution

Method resolution is fully static. There is no virtual dispatch, no vtable, no late binding. The compiler walks the extends chain at compile time, picks one declaration, and emits a direct CM-level method call.

Core algorithm

For recv.foo(args):

  1. Walk the extends chain starting at Recv, collecting all fn foo declarations on each ancestor.
  2. Walk the in-scope trait impls applicable to Recv, collecting all fn foo declarations.
  3. Combine the two sets:
    • Empty → no method 'foo' error.
    • Exactly one declaration → resolve to it. Insert an implicit upcast on recv to that declaration's owning type.
    • Two or more declarations → ambiguity error. The user must disambiguate.
  4. Emit the CM-level call to the resolved method on the resolved owning type, passing the upcast recv.

Implicit upcast in step 3 is a type-level operation only; with externref backing it lowers to a no-op at the wasm level.

Resolved corner cases

The five subtle cases below have explicit rules. All of them are validated at compile time; none of them rely on runtime checks.

(1) Override is forbidden

A child resource cannot redeclare a method with the same name as any method reachable through its extends chain. Doing so is a hard error.

pub resource Node    extends EventTarget { fn clone(&self) -> Node; }
pub resource Element extends Node        { fn clone(&self) -> Element; }  // ERROR: Element redeclares Node::clone

Reason: with no override, resolution stays purely static and Self semantics (see (4)) stay simple. WebIDL specifications avoid name collisions across an inheritance chain by convention, so this rule is rarely felt by the binding generator.

(2) Trait-vs-inherited collisions are an error

When the same method name is reachable through both the extends chain and a visible trait impl, the call is ambiguous and must be disambiguated explicitly:

pub resource Element extends Node { fn id(&self) -> String; }
trait Identified { fn id(&self) -> String; }
impl Identified for Element { ... }

el.id();              // ERROR: ambiguous — Element::id vs Identified::id
Element::id(&el);     // OK: invoke the inherent method
Identified::id(&el);  // OK: invoke the trait method

Reason: silently picking either side has a known failure mode. "Resource-first" silently shadows trait impls when a parent later grows a method; "trait-first" silently rebinds existing call sites when an impl is added. Hard error rejects both refactor hazards. Disambiguation only costs at the colliding call site, and the WebIDL/mixin pattern is curated to avoid such collisions in the first place.

(3) Static methods do not inherit

Resource-level static methods (no &self / &mut self parameter) belong to their declaring resource only. They are not reachable through subtypes.

pub resource Node    { fn create() -> Node; }
pub resource Element extends Node { ... }

Node::create();      // OK
Element::create();   // ERROR: no static method 'create' on Element

Reason: static methods correspond to a specific CM resource type's constructor / factory. Inheriting them would let a child name invoke the parent's factory, returning the parent type — confusing and not what the host implements.

(4) Self is fixed at the declaration site

Inside a method body, Self resolves to the resource that declares the method, not the dynamic / call-site type.

pub resource Node {
    fn next(&self) -> Self;   // Self == Node, always
}

let el: Element = ...;
let n: Node = el.next();      // return type is Node

Reason: with override forbidden (rule 1), there is no mechanism for a child to narrow Self. Treating Self as call-site-typed would require either a covariant override or a runtime cast — both rejected by other rules in this WEP. If a child needs a more specific result type, it declares a separate method with a different name.

(5) Visibility is judged at the declaring module

A method's visibility (pub or module-private) is evaluated against the module that declares it, not the module that declares the receiver's type.

// in dom.wado
pub resource Node {
    fn private_helper(&self);   // module-private to dom.wado
}
pub resource Element extends Node { ... }

// in user.wado
let el: Element = ...;
el.private_helper();            // ERROR: private_helper is visible only in dom.wado

Reason: extends is a type-level relation, not a name-space merge. Inheriting visibility from the child would let the child silently re-export private parent internals.

Downcast

Signature

downcast is a method synthesized by the compiler on every externref-backed resource type:

fn downcast<T>(&self) -> Option<T>;

It returns the source value re-typed as T if the host confirms the runtime type matches, otherwise None. The receiver &self is borrowed; the result is returned by value (a fresh handle to the same underlying host object).

let el: Element = ...;
if let Some(input) = el.downcast::<HtmlInputElement>() {
    input.value();             // OK: input has the full HtmlInputElement API
    input.set_attribute(...);  // OK: inherited from Element
}
el.tag_name();                 // el is still valid

Why method form, not a free function

Considered: a free downcast::<T>(v: V) -> Option<T> in the prelude. Rejected because the source-side V would have to be a generic type parameter constrained by T <: V, which forces a generic-bound syntax for subtyping (T <: V) into v1. The method form keeps the source as Self, so the compiler only checks "is the user-supplied T a strict subtype of Self?" — no new bound syntax, no new generic machinery.

Consistency with Tide WEP examples (el.downcast::<T>()) and method-chain ergonomics are secondary but reinforcing reasons.

Why value-returning, not Option<&T>

An externref-backed resource handle is itself an immutable value (its mutations all happen on the host side via &self methods — see the sidebar below). Such handles have value semantics, no lifetimes, no borrow checker. Returning Option<T> rather than Option<&T> is the honest signature: the result is a new handle pointing at the same host object, and the source remains valid in parallel. There is no borrow to track.

(i32-backed CM resources are affine — move-only with a resource-scoped borrow check — per WEP: Resource Ownership. extends is gated on externref backing, so this section concerns externref-backed resources only.)

There is no downcast_mut. Resources do not have &mut self methods (see sidebar), so a &mut-flavoured downcast has no API to feed.

Every externref-backed resource method takes &self. The Wado-side handle (an externref) carries no mutable state of its own; all observable mutation occurs in the host object referenced by that handle and is invoked through ordinary &self host calls. There is no language-level rule preventing someone from writing fn foo(&mut self) on a resource today, but in this WEP's scope (externref-backed resources participating in extends) the pattern does not arise. A future WEP can decide whether to forbid &mut self on resources outright.

i32-backed CM resources differ: in addition to &self methods they have by-value consuming methods (e.g. Request::consume_body), which transfer ownership of the receiver. Their ownership model is specified in WEP: Resource Ownership.

Allowed targets

The static type checker enforces, at the call site:

Relation between Self and T Status
T is a strict subtype of Self OK (the only valid case)
T == Self compile error (trivial cast — use the value directly)
Self <: T (i.e., T is an ancestor) compile error (use implicit upcast)
T and Self share an ancestor but neither extends the other (sibling) compile error (statically cannot succeed)
T and Self are unrelated compile error

Both Self and T must declare type="extern-ref" in #[cm(...)]. The check is redundant given the v1 gating (extends requires externref), but the compiler validates it defensively.

Generic targets are forbidden in v1

T must be a concrete type at the call site. A function like:

fn try_cast<T>(el: &Element) -> Option<T> {
    return el.downcast::<T>();   // ERROR in v1
}

is rejected. Allowing generic T requires a subtype-bound syntax (T <: Element) which is out of scope for this WEP.

Failure mode

Failed casts return None. There is no panic, no effect, no exception. Callers handle the Option<T> like any other.

Host runtime contract

For each externref-backed resource type T, the compiler emits a CM-imported predicate:

is-T: func(r: extern-ref) -> bool

This is a per-type import, not a generic is-instance(externref, type-id) form. The per-type approach keeps the CM signature shape obvious and avoids inventing a type-id encoding.

The host (e.g., the jco-style JS glue Tide ships with the bindings) implements is-T with the natural runtime check — value instanceof T for browser bindings. r.downcast::<T>() lowers to:

let v = self.0;                         // unwrap the externref
if is-T(v) { Option::Some(T(v)) } else { Option::None }

The number of is-T imports scales with the number of extends-participating resource types. For the full Tide-generated WebIDL surface this is on the order of hundreds of imports, which is well within CM limits and existing wasm-bindgen practice.

No syntactic sugar in v1

Considered and rejected for v1:

Sugar can land in a follow-up WEP once the bare method form is in real use and the right ergonomic shape is clear.

Interaction with existing features

Four interactions need explicit rules. Everything else (Default, Ord, Drop/RAII, variants holding resources, fn types, effects, stores[...], pattern matching) follows from the subtyping and method-resolution rules already established and needs no separate treatment.

Eq is auto-derived as reference equality

Every externref-backed resource auto-derives Eq. Two handles compare equal iff they reference the same host object — JavaScript's === semantics for the browser case.

Eq lowers to a host import:

is-same: func(a: extern-ref, b: extern-ref) -> bool

Cross-type comparison falls out of subtyping. el == html_input is well-typed when one operand is upcast to the other's static type; the host predicate compares the underlying refs and returns the right answer.

Ord is not auto-derived. Resources have no natural ordering and the host has no obligation to define one.

Inspect, InspectAlt, and Display delegate to the host

For resources, the auto-derived implementations of Inspect ({x:?}), InspectAlt ({x:#?}), and Display ({x}) call host-imported formatters:

inspect:     func(r: extern-ref) -> string
inspect-alt: func(r: extern-ref) -> string
display:     func(r: extern-ref) -> string

These are the one place where dynamic-type information leaks into Wado output: the host inspects the runtime type of the underlying object and renders accordingly, so {n:?} on a Node value that is actually an HTMLInputElement prints the input element. This matches the intuition that debug output is most valuable when it reflects what's really there.

A user impl Inspect for Element { ... } (or Display, etc.) shadows the auto-derived host call by the normal trait-resolution rules, with the trait-vs-inherited collision rule (rule 2 of method resolution) keeping ambiguities loud.

serde is a compile error on resources

Wado's Serialize / Deserialize are synthesized via the body-less impl Trait for Type; form (see WEP: Serde). For a resource type — or any struct or variant that transitively contains one — the synthesis fails at compile time:

pub resource Element extends Node { ... }

impl Serialize for Element;            // ERROR: cannot synthesize Serialize for resource
struct Wrapper { el: Element }
impl Serialize for Wrapper;            // ERROR: Wrapper.el is a resource

There is no silent fallback, no runtime panic, no placeholder serialization. Resources are opaque host references; their identity is meaningful only inside the running component instance, so serializing one and reading it back has no defensible semantics. A user who wants a Serialize-shaped projection writes a hand-rolled impl Serialize for Element { fn serialize(...) ... } that picks specific fields off the host object.

Option<T> and Result<T, E> are invariant — a known sharp edge

Per the subtyping rules, every aggregate is invariant in its type parameters. Option and Result are aggregates, so:

let r: Option<HtmlInputElement> = ...;
let n: Option<Node> = r;                   // ERROR: Option<HtmlInputElement> ≮: Option<Node>
let n: Option<Node> = r.map(|el| el);      // OK: upcast applies inside the closure body

Coming from languages where Option is covariant (Scala, Kotlin's nullable types, etc.), this is the most likely surprise. The same rule applies to Result<T, E> in both type parameters and to every other generic container. The .map(...) workaround is short and does not allocate.

Lowering

How extends and the operations on it lower from Wado to WIT/CM, and from WIT/CM to wasm. The recurring pattern is erasure at the CM layer: extends-related Wado types collapse to a single CM resource type, with method namespacing carrying the only distinction the host needs.

Three layers

Layer Identity
Wado each type in the extends chain is distinct (Element ≠ Node)
WIT/CM one resource type, extern-ref
Wasm the corresponding GC reference, externref

This is the same erasure pattern as Newtype Semantics: the Wado type system holds the structure, the wasm output knows nothing about it. extends differs from newtype only in that method namespacing is preserved at the WIT layer — methods are imported under per-Wado-type WIT interfaces, even though the receiver type is universal.

Universal receiver at the WIT layer

A single CM resource type:

resource extern-ref;

Every extends-related Wado type is the same extern-ref once it crosses the boundary. There is no event-target resource, no node resource, no element resource at the CM level — only the methods are split.

This eliminates a class of CM-GC requirements we would otherwise depend on (the spec accepting "the same externref valid under multiple resource types"). The single-type model needs nothing beyond standard extern-ref.

Method imports

Methods are grouped into WIT interfaces named after their declaring Wado type. The receiver argument is extern-ref:

interface event-target {
    add-event-listener: func(self: extern-ref, type: string,
                             callback: option<extern-ref>,
                             options: option<extern-ref>);
}
interface node {
    append-child: func(self: extern-ref, child: extern-ref) -> extern-ref;
    text-content: func(self: extern-ref) -> option<string>;
}
interface element {
    get-attribute: func(self: extern-ref, name: string) -> option<string>;
}
interface mouse-event {
    button: func(self: extern-ref) -> s16;
}
interface html-button-element {
    name: func(self: extern-ref) -> string;
    set-name: func(self: extern-ref, value: string);
}

Same-named methods on unrelated Wado types (e.g., a hypothetical mouse-event.button vs html-button-element.button) do not collide because the WIT interface namespace separates them.

Built-in predicates and formatters

downcast, Eq, Inspect, InspectAlt, and Display lower to flat CM imports over extern-ref:

interface lang {
    // one per extends-participating Wado type
    is-event-target: func(r: extern-ref) -> bool;
    is-node:         func(r: extern-ref) -> bool;
    is-element:      func(r: extern-ref) -> bool;
    // ...

    // universal
    is-same:     func(a: extern-ref, b: extern-ref) -> bool;
    inspect:     func(r: extern-ref) -> string;
    inspect-alt: func(r: extern-ref) -> string;
    display:     func(r: extern-ref) -> string;
}

The is-T predicates scale O(N) with the number of extends-participating types. is-same, inspect, inspect-alt, display are universal — one each, regardless of N.

Operation lowering at a glance

Wado operation WIT/CM lowering
let n: Node = el; (implicit upcast) identity
el.foo() resolving to Node::foo call node.foo(el, ...)
el.downcast::<HtmlInputElement>() call is-html-input-element(el), branch into Option::Some(el) or Option::None
a == b for a, b: ExternRef-backed call is-same(a, b)
`{x:?}` call inspect(x)
`{x}` call display(x)

Upcast and the receiver argument of inherited methods are wasm-level no-ops; the same externref value flows through unchanged.

Interaction with WIT bundling

extends is Wado-only metadata. The bundled WIT (WIT Bundling) emitted with the component contains only the flat per-Wado-type interfaces above; nothing about the inheritance relation appears. Other-language consumers of the bundled WIT see a flat method surface and do not need to understand extends to call any method.

Lifecycle

extern-ref-backed resources follow CM-GC's standard lifecycle for externref-backed resources. extends does not introduce a Wado-specific drop protocol, and the same underlying externref is GC-collected exactly once regardless of how many Wado static types referenced it.

Consequences

Implementation Roadmap

M1: Language core + #[cm(...)] extension + minimal lowering (single landing)

Lands as one milestone to avoid an intermediate "compiles but does not run" state.

M2: Downcast

M3: Built-in trait integration via host imports

M4: Bundled WIT consistency

See Also