< Blog roll

Inheritance: a web component super power

By now, you have realized that I basically only post discussions or implementations that have come up on the job. This one is no different. I want to walk you through an implementation that we did recently and highlight a super power of web components that can be very useful when used correctly.

I‘ll use a few examples that show exactly how I use inheritance to my benefit when building systems of components, and I‘ll also give a word of caution, because great power also comes with great responsibility

Gif of Mr Burns looking at photos of his parents and saying "You two never gave me anything but 100 million dollars

So we‘re full on OOP now?#

I know that seeing the word “inheritance” from the jump and knowing that I‘m a web developer that loves making UI components probably has you nervous. You‘re probably worried that this article is about to take a turn down the dreaded Object-oriented Programming (OOP) paradigm path. Am I going to recommend that web apps be completely built with classes and that the Java devs were right all along and OOP is the way? Definitely not. Rest assured, components are basically the only place where I think sprinkling a little bit of OOP principles into our libraries can make a big difference. I am not going to tell you to go full bore OOP. That would just be silly. We‘re all JS devs around here, we write scripts. But using classes can help us get organized some and thats a good thing.

What is inheritance?#

If you‘re anything like me you probably need a little bit of a primer on what “inheritance” actually is from an object-oriented programming perspective. So lets dig into what inheritance actually is a little bit before I talk about how we can use it.

Inheritance in Javascript is basically the idea that a class extends another class. We can call the two classes in this inheritance model the “parent” class and the “child” class. A simple example of inheritance might look something like this:

class Animal {
  constructor(name: string) {
    this.type = 'animal';
    this.numberOfLegs = 4;
    this.name = name;
  }

  introduce() {
    console.log(`My name is ${this.name}. I am of type: ${this.type}`);
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name);
    this.type = 'dog';
  }
}

const animal = new Animal('Aragog');
animal.introduce(); // My name is Aragog. I am of type: animal

const dog = new Dog('Fluffy');
dog.introduce(); // My name is Fluffy. I am of type: dog

In this example, the parent class is Animal and the child is Dog. When we use the extends keyword in JS, then we establish an “is a” relationship between the parent and child classes. So it can be said that because class Dog extends Animal, then a Dog is an Animal. The Dog class will “inherit” all of the properties and methods of the Animal class, but can overwrite those properties and methods if need be.

Notice that we didn‘t have to define the introduce() method in the Dog class. That method is available on the instance of Dog we created with the new Dog('Fluffy) call but is only defined on the Animal class.

That‘s inheritance. There is more to it, but that‘s all we need to know in order to talk about how we can use inheritance to our benefit when writing web components.

Design systems have natural “is a” relationships#

We‘ve talked before about how the fact that web components are backed by classes is the perfect system to represent having a single definition of some piece of UI and logic and then get to create as many instances of that definition as we want. Classes make a ton of sense when we‘re talking about components. And if we go further and start to make collections of components, like a design system, then some patterns start to emerge where inheritance can be super useful.

Think about form fields? How many types of form elements are there in a typical design system? There‘s probably a generic <text-field>, and then as your system matures you might make these:

Gif of a man in a monkey suit saying "I appreciate your input"

So a number of different types of inputs emerge, and a great question would be:

Aren‘t all of these types just text fields with more features?

Yes! There are many different kinds of input fields that are basically text fields with more features

A password field is basically the same as a <text-field> except that its type property is always password (it might flip between them with some hide/show button feature). A number field is also a text-field in nature but its type is number and there may be a masking feature to allow commas. There’s actually a secondary “is a” relationship with <masked-field> and the other fields that may be masked. A credit card field might only allow typing numbers in the field and may add spaces according to the detected card type. That‘s just a particular use case of the <masked-field> which is itself still a <text-field> in terms of all the other feature like validation, error state, helper-text options, slotted icons etc.

So right off the bat, we have some pretty good use cases for inheritance with form fields.

So if we were to organize these text field components into a hierarchy of features it might look something like this:

All the fields would inherit from text-field. number-field and password-field would inherit directly. The masked-field would inherit from text-field and add a generic masking capability, such as the ability to specify a format via a mask property. Something like mask="00000" would only allow entering exactly five numeric characters (anyone else see a possible <zipcode-field>?). Then credit-card-field and currency-field would inherit from masked-field and specify a particular mask format. Maybe the currency field would always set mask="$(0*)" and the credit-card-field would dynamically change its mask depending on the card type.

Working with inheritance#

So we‘ve decided that all of these form fields we want to make in our design system have a hierarchical relationship. Let‘s see how we would actually code this.

Here’s our base text-field class

// base TextField class in Lit for simplicity
class TextField extends LitELement {

  // validation
  @property() required: boolean;
  @property() minlength: string;
  @property() maxlength: string;
  @property() min: string;
  @property() max: string;

  @property() type: HTMLInputElement['type'];

  // a11y features
  @property() label-text: string;
  @property() helper-text: string;

  render(){
    // render the native input, native label tag, and helper-text elements
    // render any slots and basic structure
  }


}

Then lets make our number-field class

class NumberField extends TextField {
  willUpdate(){
    this.type = 'number';
  }
}

Yep. That‘s it. The willUpdate is a Lit lifecycle hook that will be called before every update when any reactive property is updated. We can use this hook to force that the type property that NumberField inherits from TextField cannot be set to anything other than 'number'. Every time the component updates, the type property is set to 'number' regardless of what the consuming dev tried to mistakenly set it to. If we‘re not adding any other new features like masking, which according to our inheritance diagram we‘re not. Then number-field is done! When we defined it as <number-field> and use it, a <number-field> will have all the same slots, validation logic and a11y considerations from text-field but we haven‘t had to repeat ourselves. Inheritance takes care of everything for us!

Now let‘s do password-field:

class PasswordField extends TextField {
  willUpdate(){
    this.type = 'password';
  }
}

Finished!

The masked-field is a little more complicated because we are adding a new feature. So that class might look something like this:

class MaskedField extends TextField {

  // add a new property on top of the existing props from TextField
  @property({type: String, attribute: 'mask-format'}) maskFormat: string;

  connectedCallBack() {
    super.connectedCallBack();

    // initialize masking library
    maskingLibrary.initialize(this.maskFormat);
  }
}

Once we have our generic masked-field class that can accept any type of mask format and initializes the masking library we‘ve chosen, we can use inheritance again to make more fields that use a specific mask format. Let‘s make the currency-field

class MaskedField extends TextField {

  connectedCallBack() {
    this.maskFormat = '$(0*)';
    // call the connectedCallBack of the `masked-field` class after setting the mask format
    super.connectedCallBack();
  }

  willUpdate() {
    // always set the maskFormat property to the desired mask
    this.maskFormat = '$(0*)';
  }
}

Inheritance can help keep your code DRY, but use caution#

All those classes are pseudo-code. The real versions of them are more complex than the blueprint I‘ve shown here. But I hope that you can see that intelligent usage of inheritance can really help prevent duplication of shared features. The inheritance pattern works really well in the situation above because we want to share almost all of the features from the parent class. Inheritance is best used when there is a very strong “is a” relationship between the parent and child and the child class will not be overriding or resetting very many of the features from the parent class. If you find yourself changing or overriding too many features from the parent, then definitely consider breaking the inheritance chain and just making two separate classes and sharing the features that need it some other way like by importing a library of shared helper functions.

Inheritance patterns are only recommended for internal use in a system. Consumers of your components with inheritance should use caution when extending components “from the outside”

Design system authors can use inheritance sparingly where there is a strong “is a” relationship and not have very many issues. The situation is a bit different for external consumers of that same system, however. Devs that install and import these component classes don‘t “own” them. Therefore, if they intend to extend them and use inheritance “from the outside” they will need to be very careful. Devs external to the system that are not in charge of version updates or potentially breaking internal changes should, at the very least, pin the version of the system classes on which they depend and make updating that version a very conscious activity. Design system devs will reserve the right to change the internals of their component classes in ways that external devs can‘t anticipate, so pinning the version is a requirement so that your code doesn‘t break unexpectedly.

In general though, I love the intelligent use of inheritance to make creating several similar components quickly and easily. If you design for this inheritance in advance, you can save yourself a lot of repeated or shared code and testing.

The End