Hyrum's law in modern frontend

Hyrum's law in modern frontend

Or the philosophy behind access modifiers, taking the shape of Private class features in modern JavaScript

Featured on Hashnode

The best professional books for you are the books that are revealing situational problems that you will encounter in your engineering journeys. I had the fortune to read about Hyrum's law in Software Engineering at Google: Lessons Learned From Programming Over Time (Great one!) and see that law happening magically just before my eyes, in a team near me.

The law says that:

With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviours of your system will be depended on by somebody

One colleague (desperately) needed to access a couple of properties of a web component that was developed and maintained by a different team, but consumed in his own micro frontend. For the simplicity of the example, let's consider the default web component generated by OpenWC's npm generator:

class AppMain extends LitElement {
  static properties = {
    header: { type: String },
  }
...
   render() {
    return html`
      <main>
        <h1>${this.header}</h1>
       ....
     `;
    }
}

Consider you'd be consuming this component in your template

<app-main></app-main>

And you, as a consumer, imagine yourself not knowing the implementation of the component, but needing to access the data rendered through the means of the header property. You'd probably select the element in DevTools, then jump to Console, type $0. and see what properties pop up. That's the exact process one colleague followed and ended up using the hidden dunder property (double underscore property) - the equivalent $0.__header in our example.

Lit's quirks

So what's up with lit shadowing any .property in a .__property? Why is there a property called __header with, surprisingly-not-so-surprisingly, the same value as header property? Well, we've run into an implementation detail that at no point was intended to be used by component consumers, or any developer building web components on top of lit. But since I can't leave you wondering, lit's actual component properties are setters and getters, whilst the true value is stored in this hidden dunder property. By the use of this wrapping, at any point, lit can detect when the setter is writing something different than what it's already stored so that the rendering cycle, as well as update hooks are triggered only then. Simple and effective!

Fun, hackish experiment: If you do set a value for __header, it won't trigger the render process, since you're bypassing lit's setter, so the template will not update. If you then do set the exact same value through the header property, again the render cycle will be skipped, because it would be compared against the value in __header.

Animation showing hidden lit element properties called accessors or descriptors

Advanced Deep Dive

If you reaaaally want to see the bottom of what I just explained, then head to lit-element's source code. What we call properties, are actually called accessors or descriptors in lit, and one can implement and configure a custom accessor, should someone need a different one, for a given property, on a given component class. As far as I can see, the property itself, created on the prototype, is an accessor, while the assigned value is called a property descriptor.

  static createProperty(
      name: PropertyKey,
      options: PropertyDeclaration = defaultPropertyDeclaration) {
    // Note, since this can be called by the `@property` decorator which
    // is called before `finalize`, we ensure storage exists for property
    // metadata.
    this._ensureClassProperties();
    this._classProperties!.set(name, options);
    // Do not generate an accessor if the prototype already has one, since
    // it would be lost otherwise and that would never be the user's intention;
    // Instead, we expect users to call `requestUpdate` themselves from
    // user-defined accessors. Note that if the super has an accessor we will
    // still overwrite it
    if (options.noAccessor || this.prototype.hasOwnProperty(name)) {
      return;
    }
    const key = typeof name === 'symbol' ? Symbol() : `__${name}`;
    const descriptor = this.getPropertyDescriptor(name, key, options);
    if (descriptor !== undefined) {
      Object.defineProperty(this.prototype, name, descriptor);
    }
  }

But what about Hyrum's law?

Lit dunder properties were never meant to be used. However, since they're observable, and usable, voilà.

Strongly typed, mature, compiled, object oriented programming languages like C++ or Java employ the use of Access Modifiers, offering developers the possibility to precisely define which members of a class can be used (depending on its type, you can read it, set it, execute it etc.). All you need is the following cheatsheet:

Access modifiers in Java

Typescript uses access modifiers as presented before, but the concept is called Member Visibility, and the list is shorter, only three - private, protected, public - where public is the default and no sane person specifies it explicitly in code. There's no need for "default", as Typescript does not have such a strong notion as packages in C#/Java. Moreover, as stated in their docs,

Like other aspects of TypeScript’s type system, private and protected are only enforced during type checking.

Javascript itself didn't have any encapsulation, until recently. It's called Private class fields and .. you guessed it, it allows you to explicitly prevent your consumers from directly accessing specified members of your class.

Class fields are public by default, but private class members can be created by using a hash # prefix. The privacy encapsulation of these class features is enforced by JavaScript itself.

class ClassWithPrivate {
  #privateField;
  #privateFieldWithInitializer = 42;

  #privateMethod() {
    // …
  }

  static #privateStaticField;
  static #privateStaticFieldWithInitializer = 42;

  static #privateStaticMethod() {
    // …
  }
}

The browser support is good, but not fabulous. The syntax is understood by Chrome >= 74 (April 2019), Safari >= 14.1 (April 2021), so if you want to go lower with supported clients, consider transpiling your production code using babel >= 7.14 which enables the required plugins by default, or include them yourself in the transpiling configuration.

I've seen great enthusiasm throughout my team for adopting this level of encapsulation, despite adding one more concern (class member access) to the JavaScript developer; a developer previously spoiled with simplicity, but simplicity that may backfire on long-lived software. As they say in Google, "Software engineering is programming integrated over time".