Extending a JavaScript class

interop

#1

From time to time we need to interop with libraries that have JavaScript classes that we need to extend. I think this trend will grow as ES2015 and TypeScript become more widespread.

Currently, I don’t have a better way to do this interop than extending the class in a [%%bs.raw ...] block and binding to the subclass with BuckleScript bindings immediately below that. E.g., https://stackoverflow.com/q/46454098/20371 .

However, since ES2015 inheritance is a syntax sugar for prototypal inheritance and is likely to stay that way, it should be possible to write inheriting classes by binding to the right sequence of calls for prototypal inheritance. For example, if I have a class:

class Animal {
  speak() {}
}

We should be able to get BuckleScript to output something like this:

var Dog = Object.create(Animal);

Dog.prototype.constructor = function(name) {
  this.name = name;
};

Dog.prototype.speak = function() {
  console.log(this.name + " says woof!!");
};

Ideally, usage would look something like this:

module Dog = Class.Extend({
  type t;

  let supertype = "Animal";
  let make(name) = Class.Instance.make({
    "name": name
  });
  let speak(t) = Class.Instance.method(instance =>
    Js.log2(instance##name, "says woof!!");
  );
});

I’m probably missing a lot of corner cases here :slight_smile: does anybody have anything else they’re doing to extend classes?


Stumped on binding to Javascript superclasses
#2

What about this perspective?

  • Extending existing ES classes is an ES concern - solve it in the ES space in a .js file.
  • When you’re done write the necessary bs/re interop module.

I think the notion of “ES2030” is more about moving forward rather than being held back by existing (class centric) JS/ES practices and conventions. And the reality of interop is that you better be comfortable with ES/JS even if you prefer to simply transpile to it.


#3

Hi Perry, extending classes in JS files or in [%%bs.raw ...] sections can certainly be done, but the way I see it, classes are not going away from JavaScript. In fact with the new syntax making them easier, they’re only going to become more prevalent in the world of ES2015+/TypeScript. My thought is that we might be able to drive extension-by-inheritance code from the BuckleScript side and give devs a nicer experience than having to fall back to JS. I’ll play around with the idea a bit more. It might well turn out that the status quo is the nicer experience after all.


#4

Thank you for bringing this up. This feature is must need for Web Component. To author web components directly with ReasonML, it needs to provide classes and extension mechanism.

As mentioned here, in this StackOverflow question, classes are must for web components

class HelloWorld extends HTMLElement {
    constructor() {
        super();
        // Attach a shadow root to the element.
        let shadowRoot = this.attachShadow({mode: 'open'});
        shadowRoot.innerHTML = `<p>hello world</p>`;
    }
}

If I try to use old ES-5 style classes, then HTMLElement doesn’t work:

function HelloWorld() {

    // Doesn't work with HTMLElement
    HTMLElement.call(this);

}

Web component is a truly great mechanism to enable polyglot programming. By providing the isolation, it paves the way for ReasonML adaptation in incremental steps.


#5

Original:

<!DOCTYPE HTML>
<html>
  <!-- https://apimeister.com/2017/06/03/writing-a-hello-world-web-component.html -->
  <head>
    <meta charset="utf-8">
    <title>Hello World</title>
  </head>
  <body>
    <hello-world></hello-world>
    <script>
     class HelloWorld extends HTMLElement {
       constructor() {
         super();
         // Attach a shadow root to the element.
         let shadowRoot = this.attachShadow({mode: 'open'});
         shadowRoot.innerHTML = `<p>hello world</p>`;
       }
     }

     customElements.define('hello-world', HelloWorld);
    </script>
  </body>
</html>

Function based version:

<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello World</title>
  </head>
  <body>
    <hello-world></hello-world>
    <script>
     /* Stripped down for clarity to Chrome support
        Version 71.0.3578.98 (Official Build) (64-bit)
      */

     /* https://github.com/webcomponents/custom-elements/blob/master/src/native-shim.js */
     function _constructHTMLElement() {
       return Reflect.construct(
         HTMLElement,
         [],
         (this.constructor)
       )
     }
     _constructHTMLElement.prototype = HTMLElement.prototype
     Object.setPrototypeOf(_constructHTMLElement, HTMLElement)

     /* based on code generated by https://babeljs.io/en/repl */
     function _extends(subClass, superClass) {
       subClass.prototype = Object.create(
         superClass && superClass.prototype,
         {
           constructor: {
             value: subClass,
             enumerable: false,
             writable: true,
             configurable: true
           }
         }
       )

       if (superClass) {
         Object.setPrototypeOf(subClass, superClass)
       }
     }

     var HelloWorld = (function () {
       _extends(HelloWorld, _constructHTMLElement)

       return HelloWorld

       // ===
       function HelloWorld() {
         var _this = Object.getPrototypeOf(HelloWorld).call(this)

         var shadowRoot = _this.attachShadow({ mode: 'open' })
         shadowRoot.innerHTML = '<p>hello world</p>'
         return _this
       }

     }())

     customElements.define('hello-world', HelloWorld)
    </script>
  </body>
</html>

Essentially the native implementations of custom elements on modern browsers didn’t bother with implementing the “traditional” method - so you end up having to polyfill it …

https://github.com/manfredsteyer/ngx-build-plus/issues/5#issuecomment-403013510

Douglas Crockford:

The Web [browser] is the most hostile software engineering environment imaginable.

And they are largely still containers of by default mutable state. Part of the appeal of OCaml is “immutability by default”. The more lax you get on mutation, the more you are trading away the benefits - at which point you might as well just throw your hands up in the air and decide that TypeScript’s structural typing is “good enough” in order the ease the pain/burden of interop.


#6

I think those are orthogonal issues. In the BuckleScript world we’ll want to extend JS classes which use inheritance as a mechanism for users to ‘hook into’ or customize their functionality. In other words, we’ll want to write classes which basically don’t contain mutable state, but just hook into existing JS classes that provide some service or other. For example, https://www.apollographql.com/docs/apollo-server/features/data-sources.html#REST-Data-Source works by having you implement fetch methods in a subclass. You don’t actually use it to deal with mutable stuff.


#7

React already uses inheritance - yet ReasonReact chose composition over inheritance.

So that’s just a stopgap measure until we can extend JS classes? Or is something else at play?

Might be something worth pondering.


#8

@peerreynders That seems to address the problem with Web Components. However, other concerns are equally valid along with the idea of immutability first approach for ReasonML.


#9

What I’m cautioning against is the “I need this, so lots of other people will need this, so BuckleScript needs this” type of attitude.

Web component is a truly great mechanism to enable polyglot programming.

Polygot programming is also about using the “right tool for the job”. So if an API forces you to play the OO game (which in my book is poor API design unless it is (a single) language-level API) maybe you should be interacting directly with that API with an OO language (e.g. JavaScript or TypeScript) and build an anti-corruption layer for your functional core (functional core/imperative shell).

For example with the introduction of Hooks React is further reducing it’s coupling to OO style practices and while OCaml supports OO style programming it tends to be rarely used (RWO).

Just because ReasonML has a JavaScript style syntax don’t mistake it for some weird, functional variant of JavaScript.

BuckleScript:

Write safer and simpler code in OCaml & Reason, compile to JavaScript.

Interop:

Interop in the context of BuckleScript, means communicating with JavaScript.

So it’s not entirely clear whether extending JavaScript classes is within the scope or part of the overall vision of BuckleScript. And even if it is, it still may be a matter of priority. The appearance of these types of features should never be taken for granted no matter how “convenient” they could be. So if you feel you need a solution right now you’d be best advised to explore alternative (less convenient) options.


#10

I think there has been a communication gap. I’m not advocating for adding a JavaScript class inheritance extension point to BuckleScript; I’m asking if anyone is writing code using existing BuckleScript features to output prototypal inheritance code, as my quick sketch code showed above.

That is indeed the approach I proposed in the linked Stack Overflow answer in my first message; however, I don’t necessarily think it’s the be-all-and-end-all for working with inheritance-heavy JS APIs. For one thing, it’s quite a lot to take in for newcomers. Hence, I’m trying to find a more elegant-looking (and safer) solution.


#11

I’m just getting caught up in this thread, but for the sake of near-term productivity (which is something that teams attempting mission critical products do care about), it’d be immensely helpful to have something like what was suggested in @yawaramin’s original post

we had an instance where we ran into a class inheritance based api and struggled with the following questions:

-> bucklescript doesn’t provide an api for this, should we write our own?
-> how do we even start to design our own approach? what are all the things we should be considering
-> what if we write our own and a standard recommendation is formed later. how big of an impact would it have on all the code we’ve already written on our homegrown solution?

I’m in complete agreement that it’s preferable to recommend against playing the OO game but the reality is that there are plenty of valuable javascript libraries that are key to building applications right now.

An escape hatch/documentation/etc for integrating these libraries will make this whole ecosystem friendlier and more approachable for people that want to replace their JS apps with Reason.


#12

I think we don’t need or want a general functionality for creating every kind of class in BuckleScript; we just need to interop with libraries that require their users to extend their classes. That means we just need to be able to override methods. So that is the approach I want to take.

Having said that, the question is should you write your own? Or wait for BuckleScript to provide one? Personally I feel it’s unlikely that BuckleScript will provide an extension to make JS class inheritance easier. Based on its core feature set, OOP is not something BuckleScript is focusing on. So I propose we write our own. In fact, I have written one, I’ll introduce it below.

So, here’s an example:

class Person {
  toString() {
    return `id: ${this.getId()}, name: ${this.getName()}`;
  }
}

exports.Person = Person;

This Person abstract class returns a nicely formatted string from a person data type, but only if you extend it and give it the getId() and getName() methods. I’ve written a function that does this extension using prototypal inheritance. Here’s the usage:

// Employee.re

// Bind to the superclass constructor function
[@bs.module "./person.js"] external super: _ = "Person";

let idField = "id";
let nameField = "name";

// Create our class's constructor function
let ctor(id, name) = {
  open Class;

  // Equivalent to `this.id = id;`
  setField(this, idField, id);
  setField(this, nameField, name);
};

// Does the prototypal inheritance
Class.(extend(~ctor, ~super, [
  // Equivalent to `ctor.getId = function() { return this.id; };`
  ("getId", [@bs.this] field(_, idField)),
  ("getName", [@bs.this] field(_, nameField)),
]));

// We still need to bind to the inherited class from BuckleScript

type t;

[@bs.new] external make: (int, string) => t = "ctor";
[@bs.send] external toString: t => string = "";

let test() = "Bob" |> make(1) |> toString |> Js.log;
// Test in node with: `require('./src/Employee.bs.js').test()`

Here is the Class module interface:

// Class.rei

type this;

[@bs.val] external this: this = "";
[@bs.set_index] external setField: (this, string, 'value) => unit = "";
[@bs.get_index] external field: (this, string) => 'value = "";

let extend: (~ctor: 'ctor, ~super: 'super, list((string, 'get))) => unit;

Implementation:

type this;

[@bs.val] external this: this = "";
[@bs.set_index] external setField: (this, string, 'value) => unit = "";
[@bs.get_index] external field: (this, string) => 'value = "";

type prototype;
[@bs.get] external prototype: 'a => prototype = "";
[@bs.set_index] external set: ('a, string, 'b) => unit = "";

let extend(~ctor, ~super, getters) = {
  super |> prototype |> set(ctor, "prototype");
  getters |> List.iter(((name, get)) =>
    set(prototype(ctor), name, get));
};

Not incredibly type-safe but definitely works.