min read

Classes are not options objects

APIs for component based libraries have long taken a declarative approach. The developer provides a set of options on how to configure a new component and the library produces the component. In Vue it looks like so:

Vue.component('button-counter', {
  data: function () {
    return {
      count: 0
    }
  },
  template: `
    <button v-on:click="count++">
      You clicked me {{ count }} times.
    </button>
  `
})

The second argument in the above example is often called an options object, because it is indeed a set of options on how to configure this new component.

In this style of API design it is the library/framework, in this case Vue, that is ultimately creating the component.

Fast-forward to a world with ES2015+ classes and we're still designing APIs that work the same way even though classes are not options objects. Here's what defining components in Vue 3 look like:

class App extends Vue {
  // initial data
  msg = 123

  // use prop values for initial data
  helloMsg = 'Hello, ' + this.propMessage

  // lifecycle hook
  mounted () {
    this.greet()
  }

  // computed
  get computedMsg () {
    return 'computed ' + this.msg
  }

  // method
  greet () {
    alert('greeting: ' + this.msg)
  }
}

In the web component space we see a lot of similar API designs. Here's the popular LitElement's

class MyElement extends LitElement {
  static get properties() {
    return {
      prop1: { type: String },
      prop2: { type: Number },
      prop3: { type: Boolean },
      prop4: { type: Array },
      prop5: { type: Object }
    };
  }
}

These libraries and others are still designing for the options object, but applying that to the class body.

But classes are not options objects. In a class it is the derived class that is the "owner" of object instances. When you get a property on an instance, myElement.prop, it is going to hit the derived class before it hits the base class.

This sort of inversion of control is subtle. You can still define APIs in the declarative way, but it has drawbacks. To implement property setters in a declarative way you need to modify the derived class' prototype and install getter/setters.

This absolutely can be done but it goes against the grain of what the role of a base class is. The role of a base class is not to define an API that a derived class implements, but rather to provide useful utilities that the derived class can use.

For example we can define ways to do type coercion and property storage like so:

class MyElement extends SuperElement {
  set prop1(val) {
    this.store('prop1', val, String);
  }

  set prop2(val) {
    this.store('prop2', val, Number);
  }
}

This might lead to slightly more verbose code for the derived class, but it is worth it in that they gain greater flexibility by controlling when the code is run.

In the next version of Bram I am working on making the API more imperative in this way. In the previous version you used a template like so:

class MyElement extends Bram.Element {
  static get template() {
    return '#some-template';
  }
}
In the next version this will become:
const template = document.querySelector('#some-template');
  
class MyElement extends Bram.Element {
  constructor() {
    super();

    this.view = this.attachView(template);
  }
}

Although more verbose, this new API has me pretty excited. By dropping the declarativeness of the API the derived class now gains control of when a view is attached (it could be in the constructor, in connectedCallback, or never) and the implementation becomes far simpler. No more parsing options and doing guess-work; it's just functions that do one simple thing.