Class Decorator

How to code an interface for Web Components with TypeScript decorators that enables declaration of the selector, styles, and template.

Implementing a class decorator is going to improve the ergonomics of Web Component development quite significantly. We'll enhance the class-based implementation which custom elements rely on by cleaning up each constructor, choosing to move the declaration of a HTML template and CSS to a new function that decorates the class. This, combined with abstracting template and style generation to reusable function will have the positive effect of reducing the boilerplate for each component. For example, when you are finished with this section, ButtonComponent will look something like this.

We're working in the packages/common directory of the monorepo for the first time, which currently only has one source file: index.ts. To keep this package organized, we should figure out a way to divide the code into logical chunks. Making a directory for all decorators seems prudent, then organizing the decorators by function.

To start developing the component decorator, create a new directory in packages/common/src/decorator, then make a new file at packages/common/src/decorator/index.ts, and another file at packages/common/src/decorator/component.ts.

Any file named index.ts manages exports for several files in the same directory. Other files in a directory contain library code.

Open packages/common/src/decorator/component.ts in your IDE to begin.

Define the Interface#

First let's define the interface used by the component decorator. We'll need this interface to keep the first argument of the decorator function type safe. Declare a new interface named ElementMeta and ensure it is exported with the export keyword.

There are three properties on this interface we should define: selector, style, and template. All three properties are optional, denoted by the ? prior to :. Each of these properties are optional to allow flexibility for the end-user. selector will be used to automatically call customElements.define in the context of the decorator, but what if the end-user for some reason has to register components in some other way? The user could omit the selector and register the component however they like. There are times an engineer may want to style the component, or not. Alternatively, there may be no need for a template, perhaps only styling. Making each property optional allows for flexibility, but as we'll observe programmatically, we'll have to check each property on ElementMeta exists and react accordingly.

Component Decorator#

TypeScript class decorators are factory functions, that is, they return a function that constructs an Object. The Object we're concerned with is the class definition of the component using this decorator. Based on the values of properties on the ElementMeta, we want to modify the prototype of the class so we can use those properties to register the component with the CustomElementRegistry and instantiate a HTML template with Shadow DOM.

The basic structure of the function is as follows: Declare a new function named Component and make sure it's exportable with the export keyword. Name the first argument of the function meta and type define the argument with the interface you just created: ElementMeta. return a function. The first argument of this function is defined by TypeScript when you use Component in the context of a class decorator. target is the class definition of the class that is being decorated. Inside this factory function, return the class definition. target should be type defined as any here because so many different class will use the decorator. It would be difficult to implement type safety here, nor should we.

What happens before the target is returned is the most interesting part of the class decorator. Before we start modifying the class definition, let's put a fail-safe mechanism in for users who haven't specified an ElementMeta in the first argument of Component.

The above conditional checks if meta is undefined and when truthy, displays a console error for developers in Dev Tools, then aborts the rest of the algorithm that depends on ElementMeta.

We have two objectives for the class decorator: register the component using the selector property, then add metadata on the class definition to later append a HTML template to Shadow DOM using the style and template properties on the ElementMeta.

Let's start with registering the component via the decorator function. In the factory function, reference the selector and pass it and the class definition, here named target, to customElements.define.

This one line allows the end-user to omit a call from customElements.define in their implementation, as you'll later observe when refactoring TextInputComponent, ButtonComponent, and CardComponent, but the user has the option to ignore selector entirely in the ElementMeta, so be sure to wrap the call to define in a conditional.

That was easy enough, although we're not yet using the class decorator for its main function: modifying the prototype of the class it decorates. This just happens to be a convenient place to also make the call to customElements.define. We can't use this space to call attachShadow and create a HTML template, then append it to the shadowRoot like we've done in previous chapters, because the class decorator doesn't have access to the instance of the class, only the definition.

Class decorators are a place where you can modify the prototype of a class, so that's exactly what we're going to do. We're going to store the ElementMeta passed through the Component function arguments on the prototype of the class, so we can later access the template and style in the constructor of the component class.

Set a new property called elementMeta on the prototype to the meta that was passed into the Component function.

If we want to avoid the style and template properties from ever being undefined, here is a good place for some conditionals that set both of these properties to empty string in the case they are undefined.

When you are finished with the Component decorator, the function should look like this:

Build the @in/common package#

Development of the Component decorator is finished, but you still need to provide the decorator and possibly the interface for use outside of the @in/common package. In previous chapters you developed components in a sibling directory at packages/component. To access the Component decorator from the files in that package, you need to properly export from the @in/common package.

To finalize development of the decorator, add the proper exports topackages/common/src/decorator/index.ts and packages/common/index.ts.

In packages/common/src/decorator/index.ts add the following exports.

Export the same from packages/common/index.ts.

index.ts is the entry point of the @in/common package. When the @in/common package is built, other packages in the monorepo will be able to reference anything exported from the index.ts.

Check the build successfully runs, which means your TypeScript is valid. This is your first time running the build command because previously you didn't really need to. Storybook was handling the development environment for all the components so far. In future chapters you'll need to build the @in/ui package as well, but for now you'll need to make sure the @in/common package is built using the following command.

If you get an error like zsh: command not found: lerna make sure lerna is installed globally and try the build command again.

If you see lerna success output in the Terminal, you are ready to proceed. Otherwise, check your TypeScript is correct.

Abstracting attachShadow#

Now that we have ElementMeta stored on the prototype of any class using the Component decorator, we can write a reusable function that could be used in the constructor of the same class to instantiate Shadow DOM much like we already did in the components developed in Part One. By abstracting this logic to a reusable function we can reduce several lines of code in each component implementation down to one line.

Basically we want to take something like this...

and reduce it to one line.

The first argument of attachShadow is the instance of the class which in the constructor you can reference as this. The second argument is the Object that configures the call to element.attachShadow. According to MDN, this Object can accept three fields: mode and delegatesFocus. The type definition exported from TypeScript's lib.dom.d.ts called ShadowRootInit reveals a third field: slotAssignment. You've set the fields on this Object in prior chapters for TextInputComponent, ButtonComponent, and CardComponent.

To start development of this new function, make a new directory named template in packages/common/src and create a new file in that directory named index.ts. Create another file in the directory named shadow.ts.

In packages/common/src/template/shadow.ts, create a new function named attachShadow and export it from the file. Declare two arguments for attachShadow: context and options, making options optional with ? and type defining context as any for now and options as ShadowRootInit, a type definition exported from lib.dom.d.ts.

Follow up in packages/common/src/template/index.ts and ensure attachShadow is exported for the main index.ts.

Finally in packages/common/index.ts export the attachShadow function.

Jumping back to packages/common/src/template/shadow.ts, fill in the algorithm for attachShadow, referencing the constructor in CardComponent at packages/component/src/card/Card.ts as a guide. Make a const named shadowRoot, type defined as ShadowRoot, equal to context.attachShadow. Since we've opted to make options optional, we need to at the very least give context.attachShadow the minimum ShadowRootInit, which requires mode.

On the next line, make a const named template equal to document.createElement('template'). This line creates a new HTML template.

Set the content of the HTML template using the ElementMeta stored on the prototype of whatever class will use this attachShadow function. Pass in context.elementMeta.style to a <style> tag and context.elementMeta.template afterward.

Finally, append a clone of the HTML template to the ShadowRoot.

When you are finished, the attachShadow function should look like this:

With Component and attachShadow now supporting autonomous and form-associated custom elements, you can now refactor CardComponent and TextInputComponent to use the new decorator pattern. Build the @in/common package again so files inside the @in/ui package can pickup the latest changes.

Refactor CardComponent and TextInputComponent#

Open packages/component/src/card/Card.ts and observe the current state of the CardComponent class. Before you replace the content in the constructor, import Component and attachShadow from @in/common.

This lesson preview is part of the Fullstack Web Components course and can be unlocked immediately with a \newline Pro subscription or a single-time purchase. Already have access to this course? Log in here.

Unlock This Course

Get unlimited access to Fullstack Web Components, plus 70+ \newline books, guides and courses with the \newline Pro subscription.

Thumbnail for the \newline course Fullstack Web Components