component-model icon indicating copy to clipboard operation
component-model copied to clipboard

What's the best way to model OOP-style inheritance in WIT?

Open jcbhmr opened this issue 4 months ago • 1 comments

Situation: I want to map the JS DOM Node, Element, HTMLElement, etc. classes to WIT somehow so that I can import them from the JS host in my Rust WASM Component. These DOM classes have an inheritance hierarchy. I am unsure how to best map these concepts to WIT resource types.

yes i could just use wasm_bindgen ecosystem but this is more hypothetical experiment-y than practical i-need-this-for-a-business-project

Option 1: T::cast_from_super(u) and t.cast_to_super() owned to owned conversions

  • avoids duplicating methods for each class in the chain
  • lots of consuming conversions to go back and forth
  • the most barebones of the bunch; also the most cross-wasm func calls
  • i dont think WIT can do methods that consume (not borrow) the self param so these would be static methods i guess
// document.querySelector() returns Element | null
let element = document.query_selector("input[name=username]").unwrap();
let html_element = HtmlElement::cast_from_super(element).unwrap();
let html_input_element = HtmlInputElement::cast_from_super(html_element).unwrap();
// use the <input> tag with things like .value or .reportValidity()
html_input_element.get_value();
html_input_element.report_validity();
// cast back to Element to use Element.prototype.id
let html_element = html_input_element.cast_to_super();
let element = html_element.cast_to_super();
element.get_id();
element.set_id("username");

Option 2: T::cast_from_super(u) and t.cast_to_super() owned to owned conversions but with inherited methods duplicated on extended classes

  • lots of duplicating base class methods for all classes that extend it
  • less cast_to_super, still walk the cast_from_super chain
  • EACH METHOD on EACH CHILD TYPE which can be A LOT
// document.querySelector() returns Element | null
let element = document.query_selector("input[name=username]").unwrap();
let html_element = HtmlElement::cast_from_super(element).unwrap();
let html_input_element = HtmlInputElement::cast_from_super(html_element).unwrap();
// use the <input> tag with things like .value or .reportValidity()
html_input_element.get_value();
html_input_element.report_validity();
// DON'T cast back to Element to use Element.prototype.id
html_input_element.get_id();
html_input_element.set_id("username");

Option 3: T::from_U(&) and (&t).as_U() borrowed to owned-copy methods

  • two owned resources for the same underlying value -- can't return borrow<T> iirc?
  • can skip to the end of the inheritance chain in one function instead of walking it manually
// document.querySelector() returns Element | null
let element = document.query_selector("input[name=username]").unwrap();
let html_input_element = HtmlInputElement::from_element(element).unwrap(); // see also from_html_element() and from_node()
// use the <input> tag with things like .value or .reportValidity()
html_input_element.get_value();
html_input_element.report_validity();
// reuse element to use Element.prototype.id
element.get_id();
// or create another owned copy by downcasting
let element2 = html_input_element.as_element();
element2.set_id("username");
do_something_with_element(element); // takes owned Element instance, not HtmlInputElement
// somehow we still have our own owned HtmlInputElement instance AND element2

Option 4: T::from_U(&) and (&t).as_U() borrowed to owned-copy methods but with inherited methods duplicated on all extending classes

  • still two owned resources
  • basically only from_U() unless need to make T into U for a method argument
  • EACH METHOD on EACH CHILD TYPE which can be A LOT
// document.querySelector() returns Element | null
let element = document.query_selector("input[name=username]").unwrap();
let html_input_element = HtmlInputElement::from_element(element).unwrap(); // see also from_html_element() and from_node()
// element is still owned too!
// use the <input> tag with things like .value or .reportValidity()
html_input_element.get_value();
html_input_element.report_validity();
// use Element.prototype.id dup'd on all classes that extend Element
html_input_element.get_id();
html_input_element.set_id("username");
do_something_with_element(element); // takes owned Element instance, not HtmlInputElement
// somehow we still have our own owned HtmlInputElement instance

Option 5: T::from_U(u) and t.into_U() owned to owned conversion methods for ALL parent class types

  • quicker shortcuts than option 1
  • still have to cast back and forth for inherited methods
// document.querySelector() returns Element | null
let element = document.query_selector("input[name=username]").unwrap();
let html_input_element = HtmlInputElement::from_element(element).unwrap(); // see also from_html_element() and from_node()
// use the <input> tag with things like .value or .reportValidity()
html_input_element.get_value();
html_input_element.report_validity();
// cast back to Element to use Element.prototype.id
let element = html_input_element.into_element(); // see also into_html_element() and into_node()
element.get_id();
element.set_id("username");

Option 6: T::from_U(u) and t.into_U() owned to owned conversion methods for ALL parent class types

  • most ergonomic i think; also minimal cross-wasm func calls
  • have to dup for each conversion from_T() and into_T()
  • EACH METHOD on EACH CHILD TYPE which can be A LOT
// document.querySelector() returns Element | null
let element = document.query_selector("input[name=username]").unwrap();
let html_input_element = HtmlInputElement::from_element(element).unwrap(); // see also from_html_element() and from_node()
// use the <input> tag with things like .value or .reportValidity()
html_input_element.get_value();
html_input_element.report_validity();
// DON'T cast back to Element to use Element.prototype.id
html_input_element.get_id();
html_input_element.set_id("username");
do_something_with_element(html_input_element.into_element()); // see also into_html_element() and into_node()

Option 7? are there other ways im missing?

i havent manually tested making bindings and running each of these rust code options in a js runtime that provides these import to it (too much effort). im looking for some feedback on how to model the general "class T extends U" scenario in WIT. this is just an example scenario that im having right now. Which one of these best fits the WIT vibes? are the "shortcut"-style methods encouraged in WIT (since they incur an extra across-component-boundary func call) or is that so fast nowadays that it shouldnt matter? what about if its worth the extra bindings size to duplicate the entire HTMLElement prototype attribute list (60+ getter/setter methods) for EVERY SINGLE HTML<something>Element subclass -- should that be done in the guest language (impl in Rust instead of in WIT)?

looking for more opinions

jcbhmr avatar Sep 12 '25 18:09 jcbhmr

Great question and thanks for the thorough write-up! One C-M/WIT extension which we've discussed but haven't yet had time to work on that I think would help out this use case is the ability to declare that one resource type is a subtype of another.

In WIT, extending the resource syntactic sugar, this extension would let you write something roughly in the shape of:

resource r1 {
  m1: func();
}
resource r2 extends r1 {
  m2: func();
}

and subtyping would allow an r2-typed handle to be passed as the self parameter of m1. This would hopefully avoid the method-duplication problem you're describing.

Given that, for the downcasting question, I think I'd go the route of:

resource r2 extends r1 {
  cast: static func(source: borrow<r1>) -> result<r2>;
}

This does mean multiple owned handles aliasing to the same underlying DOM node, but I think this aliasing is somewhat unavoidable given the nature of DOM APIs and already arises for, e.g., getElementById and querySelector.

lukewagner avatar Sep 15 '25 15:09 lukewagner