Skip to content

RFC: Class hierarchies in WebAPI, and developing WebAPI, React and the compiler together #9058

Open

Description

@cknitt

Summary

ReScript v13 lets a type say which JavaScript class its values are instances of, and an untagged variant then tells its cases apart with instanceof, subclasses first. This already replaces hand-written classifiers: matching re-classifies a value by its runtime class.

WebAPI models interfaces as records, and records can't carry a class yet. This RFC proposes two compiler features for that, A and B, plus one prerequisite fix, and leaves a third question, C, open. It also proposes how to develop WebAPI, the React bindings and the compiler together while these features settle, and asks the WebAPI team for feedback on both.

What the compiler does today (v13)

A type declares its runtime class with @instanceOf, or its typeof with @typeOf:

@instanceOf("Animal") type animal
@instanceOf("Dog") type dog = private animal   // Dog is a subclass of Animal
  • A subclass is a private abbreviation of its superclass: (dog :> animal) upcasts, but an animal is never taken for a dog.
  • An untagged variant tests its cases in a safe order: literals first, then typeof primitives, then classes, subclasses before their superclasses, then functions and objects:
@unboxed type pet = Dog(dog) | Animal(animal) | Name(string)
if (typeof pet === "string") { ... }
else if (pet instanceof Dog) { ... }
else { ... }   // Animal: the last case of a complete match is not checked
  • A class introduced on an abbreviation must be private, so two names for one type can't claim two different classes. That is how the stdlib's typed arrays became distinct types in v13 (Make typed arrays distinct types #9050): Int8Array.t is private TypedArray.t<int>.
  • Classification needs no helper. A constructor doesn't fix which case a value matches, so a value wrapped in its superclass case is re-classified by matching:
let isDog = (a: animal) =>
  switch Animal(a) {        // `Animal(a)` compiles to `a`
  | Dog(_) => true          // `a instanceof Dog`
  | Animal(_) => false
  }

Bindings can also type fields and results as the union directly, so that users just match on them.

Why this matters for WebAPI

With class annotations, WebAPI could:

  • drop the hand-written classifiers (isInstanceOf via %raw, Element.classify, FillStyle.decode). Fields and results that can hold several classes get a union type, and users match on it;
  • write sound unions such as blobPart = String | Blob | ArrayBuffer | TypedArray. The ArrayBuffer and typed-array cases are commented out today; they work now that typed arrays are distinct types;
  • upcast with :> instead of %identity, once the compiler knows which record extends which (B below).

What doesn't work yet

WebAPI models an interface as a record of its fields, inherited fields included, with methods coming from Impl functors:

type blob = {size: float, @as("type") type_: string}
type file = {...blob, name: string, lastModified: float}

Three things stand between that and class annotations:

  1. @instanceOf can't go on a record definition. @instanceOf("Blob") type blob = private {...} is rejected ("only abstract types and type abbreviations"). The workaround of a fields record plus @instanceOf("Blob") type blob = private blobFields doesn't help either: fields can't be read through a private abbreviation, so b.size stops compiling.
  2. Record inheritance isn't visible to the compiler. file contains blob's fields but isn't an abbreviation of blob, so nothing says File is a Blob. In Blob(blob) | File(file) the instanceof Blob check would come first and take every File. For the same reason (file :> blob) between private records is refused: structural coercion into a private record would let any record with blob's fields pass as a Blob (see Prerequisites).
  3. Records carrying a class must be private, or {size: 1.} would type as a blob and be dispatched as one. WebAPI records have mutable fields (element.id, innerHTML, …). Writing a mutable field of a private record is warning 111 since ReScript 13.0.0-alpha.5 (make mutation of private record mutable fields a configurable warning instead of a hard error #8366), so every DOM property write would warn. This is probably why the WebAPI sources still say "TODO: mark as private once mutating fields of private records is allowed".

Considered: abstract types with accessors

Interfaces could instead be abstract class types, @instanceOf("Blob") type blob and @instanceOf("File") type file = private blob, with properties read and written through generated @get/@set externals. That needs no compiler change: ancestry, recognition and :> each already have one mechanism. The cost is record syntax, b->Blob.size instead of b.size, and the generator emitting the inherited accessors for every type.

WebAPI chose records deliberately, for the developer experience, so this RFC continues with records. Abstract types remain a fallback that works today.

Prerequisites for A

Proposal: compiler side

A. @instanceOf on private record definitions. The record type becomes a class case, dispatched with instanceof, and its fields stay readable. It must be private, by the same rule as above. A parameterized private record works like a parameterized abstract type: the dispatch ignores the parameters, so a variant case can use a concrete instantiation (Element(element<unknownNativeElement>)).

B. An explicit superclass: @extends.

@instanceOf("Blob")
type blob = private {
  size: float,
  @as("type") type_: string,
}

@instanceOf("File") @extends(: blob)
type file = private {
  ...blob,
  name: string,
  lastModified: float,
}

let describe = (f: file) => `${f.name}: ${Float.toString(f.size)} bytes`  // fields readable
let asBlob = (f: file) => (f :> blob)                                     // plain upcast

@unboxed
type blobPart =
  | Text(string)
  | Blob(blob)
  | File(file)
  | Buffer(ArrayBuffer.t)
  | Bytes(Uint8Array.t)
if (typeof part === "string") { ... }
else if (part instanceof File) { ... }   // before Blob, because of @extends
else if (part instanceof Blob) { ... }
else if (part instanceof ArrayBuffer) { ... }
else { ... }
  • @extends(: blob) names a type, so it works across modules (@extends(: Blob.t)) and inside recursive type groups. That matters for the DOM, which is largely one recursive group.
  • Inherited fields can be spread from a parameterized fields record defined outside the group, as the React Native bindings already do:
    type nodeFields<'node, 'element> = {parentNode: null<'node>, parentElement: null<'element>}
    
    @instanceOf("Node")
    type rec baseNode = private {...nodeFields<node, element>}
    @instanceOf("Element") @extends(: baseNode)
    and element = private {...nodeFields<node, element>, tagName: string}
    @unboxed
    and node = Element(element) | Other(baseNode)
    @extends checks the fields after spreads are expanded, so this pattern works and gets a test case. A union in the same recursive group as its classes dispatches subclass-first, as it does for abstract class types today.
  • The compiler would check that the named type is a class, and that the record has all its fields with the same types, so that :> is valid. It would report a missing @instanceOf, a non-class supertype, missing fields and cycles as errors.
  • Field composition and class inheritance are different facts: a spread of mixin fields implies no ancestry. So the explicit form comes first. Inferring the superclass from a spread could be an optional shorthand later.

C. Writable mutable fields on private records. This is a decision about private records in general, not about classes, so it is open and best decided on its own:

  • C1. No warning 111 for fields declared mutable in a private record: private then means "can't be constructed outside its module", not "can't be written". This is the simpler option, with no new attribute.
  • C2. An opt-in attribute on the type allows writes, keeping today's warning by default. This keeps write permission separate from runtime recognition, at the cost of an opt-in.
  • C3. Keep the warning, and leave WebAPI's records public, without classes.

Proposal: WebAPI side (once A, B and C exist)

  • The generator emits @instanceOf("X") and @extends(: parent) for every IDL interface with a global constructor. Mixins (ParentNode, GlobalEventHandlers) and dictionaries get no class, since dictionaries are plain objects.
  • Interface records become private.
  • %identity upcasts (asNode, asBlob, …) become :>, which B makes valid.
  • Fields and results that can hold several classes are typed as unions, which replaces isInstanceOf, classify and decode.
  • Unions like blobPart get their ArrayBuffer and typed-array cases back.

Proposal: developing WebAPI, React and the compiler together

  • Core React stays platform-agnostic, because rescript-react-native builds on it. Only a React DOM part depends on WebAPI, instead of on the stdlib's Dom module. React DOM uses element, eventTarget, event, window, history, location, touchList, dataTransfer and eventPointerId from there. How to split the bindings is up to rescript-react.
  • The stdlib's web types go away. Dom, and File, Blob and similar, are leftovers in the stdlib. The proposal is to deprecate them in 12.x and remove them in v13, with WebAPI as the replacement.
  • The compiler repo vendors a WebAPI base package, as it vendors the React bindings today (tests/dependencies/rescript-react). The tests, examples and playground need React DOM, which then needs the base package. The vendored subset is what React DOM needs: EventTarget, Node, Element and HTMLElement, the event types, Window, History, Location, DataTransfer, TouchList and the style object.
  • While the features above are in flux, one compiler PR changes the compiler, the vendored WebAPI and the vendored React bindings together. That way every compiler feature is tried on real bindings before it's final. During that time the vendored copy is authoritative for the base subset. A VENDORED.md records the upstream commit and lists local deviations, and the changes are ported back into the WebAPI generator in batches.
  • Long term, the base package comes from the WebAPI repo, as the React bindings come from rescript-react, and the compiler repo only vendors a copy. Once A, B and C are released, upstream is authoritative again and the copy is re-synced from the generator.

DOM values on the server

Server runtimes (Node, Deno, Bun) have no DOM: there is no global Element, Node or Window. On the server, a class check like x instanceof Element therefore throws ReferenceError: Element is not defined. The compiler will not guard these checks, because DOM values should not show up in server code at all, and the error names the missing class. Primitives and literals never reach a class check.

React Native 0.85 installs Node, Element, HTMLElement, CharacterData, Text and Document as globals at app startup, so class dispatch works there.

Possible follow-up

Coercing a case's payload type into an @unboxed union, (e :> node), as sugar for the constructor Element(e). It would follow the existing rule that lets string coerce into an @unboxed variant with a string case. It isn't needed for this RFC.

Proposed order

  1. Refuse coercion into private records (Refuse coercing another type into a private record or variant #9125).
  2. Vendor the WebAPI base subset unchanged, and switch the vendored React DOM bindings from the stdlib's Dom to it.
  3. Deprecate the stdlib's web types in 12.x; remove them in v13.
  4. A and B in one compiler PR, updating the vendored WebAPI and React bindings.
  5. C in its own compiler PR, once decided.
  6. Port the changes to the WebAPI generator and re-sync.

Questions for the WebAPI team

  1. C: which of C1, C2 and C3 would you choose?
  2. Which modules belong in the base package that React DOM depends on, and should it be published as its own npm package from the WebAPI repo?
  3. Are you fine with the vendored copy leading during the transition, with batched ports back to the generator?

Activity

  1. Freddy03h commented on Oct 26, 2026

    @Freddy03h

    This comment was drafted by Claude from the rescript-react-native bindings and the React Native sources.

    Feedback from the React Native side. RN ships a small, read-only DOM (Node, Element, Text, Document). That makes it a convenient small test case for this RFC, and it is the example from #8280, so here it is rewritten with A + B.

    One prerequisite: instanceof dispatch needs the classes to exist as globals, otherwise it throws a ReferenceError (as the RFC notes for server runtimes). React Native does provide them: in RN 0.85, Node, Element, HTMLElement, CharacterData, Text and Document are installed as globals at app startup. So the approach works on RN.

    Today (simplified)

    type readOnlyNode<'node, 'document, 'element> = {
      childNodes: nodeList<'node>,
      nodeType: int,
      ownerDocument: null<'document>,
      parentElement: null<'element>,
      parentNode: null<'node>,
    }
    
    type rec node = {...readOnlyNode<node, document, anyElement>}
    and element<'nativeElement> = {
      ...readOnlyNode<node, document, anyElement>,
      children: htmlCollection<anyElement>,
      tagName: string,
    }
    and text = {...readOnlyNode<node, document, anyElement>, data: string}
    and document = {...readOnlyNode<node, document, anyElement>, documentElement: anyElement}
    and anyElement = element<unknownNativeElement>
    
    // + `asNode` upcasts via %identity in a Node.Impl functor
    // + a hand-written classifier on nodeType with Obj.magic
    module NodeType = {
      type t = Element(anyElement) | Text(text) | Document(document) | Unknown
      let classify = (node: node) =>
        switch node {
        | {nodeType: 1} => Element(node->Obj.magic)
        | {nodeType: 3} => Text(node->Obj.magic)
        | {nodeType: 9} => Document(node->Obj.magic)
        | _ => Unknown
        }
    }

    With this RFC

    // fields template, outside the recursive group
    type nodeFields<'node, 'document, 'element> = {
      childNodes: nodeList<'node>,
      nodeType: int,
      ownerDocument: null<'document>,
      parentElement: null<'element>,
      parentNode: null<'node>,
    }
    
    // the type used everywhere: a union dispatched with instanceof
    @unboxed
    type rec node =
      | Element(anyElement)
      | Text(text)
      | Document(document)
      | Other(baseNode)
    
    @instanceOf("Node")
    and baseNode = private {...nodeFields<node, document, anyElement>}
    
    @instanceOf("Element") @extends(: baseNode)
    and element<'nativeElement> = private {
      ...nodeFields<node, document, anyElement>,
      children: htmlCollection<anyElement>,
      tagName: string,
    }
    
    @instanceOf("CharacterData") @extends(: baseNode)
    and characterData = private {...nodeFields<node, document, anyElement>, data: string}
    
    @instanceOf("Text") @extends(: characterData)
    and text = private {...nodeFields<node, document, anyElement>, data: string}
    
    @instanceOf("Document") @extends(: baseNode)
    and document = private {
      ...nodeFields<node, document, anyElement>,
      documentElement: anyElement,
    }
    
    and anyElement = element<unknownNativeElement>
    
    // no classify: fields are already typed with the union
    let describeParent = (t: text) =>
      switch t.parentNode {
      | Null.Value(Element(e)) => `<${e.tagName}>`
      | Null.Value(Document(_)) => "#document"
      | Null.Value(Text(_) | Other(_)) | Null.Null => "none"
      }
    
    let asNode = (e: anyElement) => (e :> node) // see question 2
    function describeParent(t) {
      let match = t.parentNode;
      if (match === null) { return "none"; }
      if (match instanceof Element) { ... }
      else if (match instanceof Text) { ... }
      else if (match instanceof Document) { ... }
      else { return "none"; }
    }

    This covers what #8280 asked for better than the original proposal. These values are never constructed in ReScript, so with private records the identity concern raised there goes away, and the hand-written classifier is gone. If this lands, #8280 can be closed.

    Remarks / questions

    1. Spreads in recursive groups. The RFC says the generator has to write inherited fields out. A pattern that already works: a parameterized fields record defined outside the group, spread inside it (...nodeFields<node, document, anyElement>). I assume @extends(: baseNode) accepts this, since the field check runs after spread expansion. It would be worth a test case.
    2. Case type → union. With node being the union, the Node.Impl functions (contains, compareDocumentPosition, …) take a node, so an element has to go up into it. Could (e :> node) be allowed when the unboxed variant has a case whose payload is e's type (or one of its superclasses)? It's sound, since the runtime value is unchanged, and it would remove the last %identity.
    3. Unboxed variant inside the recursive group. Here the union lives in the same type rec group as the class records it dispatches on, so that fields can point to it directly. Is that supported, i.e. are the @instanceOf / @extends of the group's records known when the variant's dispatch order is computed? This is what removes the need for a separate classify.
    4. Type parameters. element<'nativeElement> has a phantom parameter (the native component type of a ref). I assume @instanceOf on a parameterized private record works like TypedArray.t<'a>. Can a variant case use a concrete instantiation (Element(anyElement), i.e. element<unknownNativeElement>)?
    5. C. It doesn't matter for RN: its DOM is read-only (ReadOnlyNode, ReadOnlyElement, …), so private records fit as-is. No objection to C1.
    6. Spelling. @instanceOf("Element") @extends(: baseNode) reads well to me.

    React bindings on top of WebAPI

    I'm fine with React building on WebAPI only if the bindings are split into React and React DOM, with only the React DOM part depending on WebAPI. The core of React (components, hooks, JSX, refs, context) is platform-agnostic, and rescript-react-native depends on it. RN has no window, history or location, and it has its own Element. If core React pulled in WebAPI, every RN project would get the web DOM types, which would conflict with RN's own DOM. The DOM-specific parts (ReactDOM, DOM props, synthetic DOM events, window / history / location, …) would live in a separate React DOM package, the only one that depends on WebAPI.

  2. cristianoc commented on Oct 27, 2026

    @cristianoc
    Collaborator

    Some findings from trying this out against master (1a49d91), which may change the shape of the proposal.

    Classification already works with ordinary constructors

    An untagged constructor does not fix which case a value matches (UntaggedVariants.md, rule 8). Wrapping a value in its catch-all case and matching on it selects the most specific class:

    @instanceOf("Animal") type animal
    @instanceOf("Dog") type dog = private animal
    @unboxed type pet = Dog(dog) | Animal(animal)
    
    let classify = (a: animal) => Animal(a)   // compiles to `return a`
    let isDog = (a: animal) =>
      switch classify(a) {
      | Dog(_) => true                        // compiles to `a instanceof Dog`
      | Animal(_) => false
      }

    isDog(new Dog()) is true and isDog(new Animal()) is false. So WebAPI's classify / decode can become ordinary (generated) functions over an untagged variant with a covering superclass case, with no %identity and no new feature. Allowing (a :> pet) would only spare writing the constructor.

    Two prerequisites for @instanceOf on private records (A)

    1. Coercion into a class record must be refused. Today a plain record coerces into a private one with the same fields:
      type plain = {size: float}
      type blob = private {size: float}
      let fake = (p: plain) => (p :> blob)   // compiles
      If blob carried @instanceOf("Blob"), a literal could pass as a Blob this way, so being private would not be enough.
    2. Record coercion ignores mutable (Record coercion can turn an immutable field into a mutable one #9063): {x: float} coerces to {mutable x: float}. WebAPI's writable fields would inherit this hole through :>.

    On B and C

    • An explicit @extends seems right: field composition and class inheritance are different facts (mixin fields imply no ancestry), and recursive groups need the explicit form anyway. Inference from spread can stay an optional shorthand.
    • C1 changes what private means for every record, independently of classes, so it may be better decided on its own. If A goes ahead, an opt-in for writable fields (C2) keeps write permission separate from runtime recognition; exempting only @instanceOf records would tie the two together.

    An alternative that needs no compiler change

    Model interfaces as abstract class types rather than records: @instanceOf("Blob") type blob, @instanceOf("File") type file = private blob, properties through generated @get/@set externals, and generated classifiers as above. Ancestry, recognition and property access then each have one existing mechanism, and :> upcasts already work ((d :> animal) compiles to return d). The cost is record syntax: Blob.size(b) instead of b.size, and the generator emits the inherited accessors instead of using record spread.

    So the question for the WebAPI team is mainly this trade: keep record syntax with A + B + an answer to C (plus the two prerequisites), or move to abstract types with accessors and need nothing new from the compiler.

    Unchanged by any option: interfaces without a global constructor (mixins) cannot be cases, instanceof fails across realms, and @instanceOf declarations are trusted rather than checked.

  3. cknitt commented on Oct 29, 2026

    @cknitt
    MemberAuthor

    Thanks both, this is very helpful.

    Records or abstract types. The abstract-types alternative is appealing because it needs no compiler work. As far as I know, though, the WebAPI team chose records deliberately, for the developer experience. So the RFC continues with records: A + B, the prerequisites, and a separate answer for C. If the WebAPI team sees it differently, the abstract-type route is a good fallback that works today.

    @cristianoc:

    • Classification works today. Good point: a value wrapped in its superclass case is re-classified by matching, so hand-written classifiers like classify/decode aren't needed at all. One matches on the value wrapped in its catch-all case, or the bindings type fields and results as the union directly, as in Freddy's sketch. The RFC implied A/B were needed for this, and that should be corrected.
    • Coercion into a private record. Agreed, this has to be refused for A to be sound. I'd refuse it for every private record, not only class records: private means the record can't be constructed outside its module, and :> from a plain record is a way to construct it. If that turns out to break real code, it can be narrowed to records with @instanceOf.
    • Mutability in record coercion: fixed in the meantime by Check field mutability in record coercion #9086 (Record coercion can turn an immutable field into a mutable one #9063).
    • B: agreed. The explicit @extends comes first; inference from a spread can be an optional shorthand later.
    • C: agreed that it's a decision about private records in general, not about classes, so it's best decided on its own. Both options have a case. C1 is simpler: mutable fields of private records are just writable, with no new attribute. C2 keeps write permission separate from runtime recognition, at the cost of an opt-in. I'd leave the choice open for now.

    @Freddy03h:

    1. A parameterized fields record spread inside the group: yes. @extends checks the fields after spreads are expanded, so this pattern works. It should become a test case.
    2. (e :> node): the constructor already does it, as @cristianoc showed: Element(e) compiles to e, and matching re-classifies. A coercion would be sugar on top. It would fit the existing rule that lets string coerce into an @unboxed variant with a string case, so it's a reasonable follow-up, but not needed for this RFC.
    3. The union in the same recursive group: yes. I checked with abstract class types: a variant in the same type rec group dispatches subclass-first (instanceof Text, instanceof Element, then the Node catch-all). Records with A take the same path.
    4. Type parameters: yes. @instanceOf works on parameterized types, and the dispatch ignores the parameters, so Element(anyElement) is fine.
    5. React vs React DOM: agreed. Core React has to stay platform-agnostic for rescript-react-native, so only a React DOM part should depend on WebAPI. How to split the bindings is up to rescript-react.

    I've folded this into the RFC text; objections welcome.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions