💅 Decorators, TypeScript, and Stencil

Introduction

The TypeScript team has recently merged a PR implementing the stage 3 TC39 proposal for implementing decorators in ECMAScript. This development has some consequences for how we handle decorators in Stencil, where they have long been used as the syntactic lever for a lot of the basic functionality that we provide to component authors. With that in mind, I wrote this document to summarize the state of the decorator world and facilitate getting us on the same page about where we are and where we should go.

Terms

A few definitions that I’m using throughout this document to distinguish between a few closely related things.

What is covered by the TC39 decorator proposal?

The TC39 decorator proposal was advanced to stage 3 in April 2022, indicating that it is very likely to be accepted as part of the ECMAScript standard in something very close to its present form. It standardizes a notion of decorators in ECMAScript as functions which correspond to the following interface:

type Decorator = (value: Input, context: {
  kind: string;
  name: string | symbol;
  access: {
    get?(): unknown;
    set?(value: unknown): void;
  };
  private?: boolean;
  static?: boolean;
  addInitializer?(initializer: () => void): void;
}) => Output | void;

Using the @decoratorname syntax, functions corresponding to this interface can be applied to classes, class fields, class methods, and class accessors. That could look, for example, something like this:

class Example {
  @reactive accessor myBool = false;
}

Multiple decorators can be applied to the same thing, and they are evaluated left to right, top to bottom. A decorator can do essentially three things:

If we consider the example of attaching a decorator to a class field (as Stencil component authors are used to doing), decorators could be used to update the value on the class at runtime, to trigger some side effect with the value, set up a subscription, etc. They are quite flexible and provide a first-class way to re-use small bits of functionality.

The standardization hews quite close to an informal and loose ‘standard’ that has emerged for decorators in the JS world, defined in practice by the implementations of series of different tools like transpilers and so on (these were in part based on earlier versions of the TC39 proposal). Stencil itself implements a dialect of this loose standard, having used its own implementation of decorators for several years. However, there are some important differences between ES Decorators, Stencil Decorators, and TS Decorators which have implications for Stencil as a tool.

The full TC39 proposal contains a lot of detailed information and I won’t attempt to summarize all of it here. It’s not a bad idea to give it a read through I think!

TypeScript’s decorator support

Here I want to just briefly review the state of TypeScript support, before and after the recent PR which implemented support for ES Decorators. I would suggest reading through the PR description as well.

TS Decorators

In current stable releases of TypeScript, support for decorators can be enabled by setting the --experimentalDecorators configuration flag. With this option turned on you can use decorators mostly along the lines of what’s explained in the TC39 proposal, with a few important differences.

Recall that decorators as enabled by this option in TypeScript are referred to as TS Decorators.

Example

Here’s a quick example which decorates a class field and just logs its arguments (TS playground):

const MyDecorator: () => PropertyDecorator = () => {
  return (target: Object, propertyKey: string | symbol) => {
    console.log({ target, propertyKey });
  };
};

class MyComponent {
  @MyDecorator()
  decoratedProp: string = "foobar";
}

If you run this you’ll see { "target": {}, "propertyKey": "decoratedProp" } in the console. It will compile to the following JS:

var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
const MyDecorator = () => {
    return (target, propertyKey) => {
        console.log({ target, propertyKey });
    };
};
export class MyComponent {
    decoratedProp = "foobar";
}
__decorate([
    MyDecorator(),
    __metadata("design:type", String)
], MyComponent.prototype, "decoratedProp", void 0);

As you can see TypeScript basically includes a polyfill / helper (__decorate) which implements the TypeScript vision for how decorators should behave.

Differences with ES Decorators

So that all seems straightforward enough, and looks quite a bit like how ES Decorators are designed to work. However, there are some differences, mainly in the specific values that decorator functions will be called with. For instance, TS Decorators which decorate a class property (like our MyDecorator example above) receive two arguments, the first being the constructor of the class for a static member or the prototype of the class for an instance member and the second being the name of the member.

ES Decorators, by contrast, receive two arguments, value and context, with both differing based on what exactly is being decorated. In the case of class fields value will be undefined, and the name of the field can be found on the context object.

Decorator factories, a pattern that TS Decorators have supported for a while, is something that works fine with ES Decorators as well. This might look something like this:

class Foo {
  @log('info')
  myField = "hey!";
}

The log decorator factory takes an argument (a log level, in this case) and then returns a decorator. This pattern can work with both TS Decorators and ES Decorators, which matters for us since the way that Stencil Decorators are used is basically as if they are decorator factories, meaning we call the Prop decorator like this:

class Component {
  @Prop( /* optional params here */ )
  foo: string = "hey";
}

So it’s a good thing that no matter what we end up deciding to support in Stencil going forward this pattern is the normal way to pass options into a decorator.

ES Decorators in TypeScript 5.0

As of TypeScript 5.0, which is currently in beta, TypeScript supports ES Decorators as specified by the TC39 proposal as well as the now-legacy TS Decorators which it has supported for a while. TS Decorators (the previous decorator implementation in TypeScript) are now feature-flagged behind the experimentalDecorators option, while the new implementation of ES Decorators is ‘on’ by default. Obviously since these two types of decorators each ‘claim’ a chunk of syntax (@decoratorName) they cannot both be used at once.

Here’s an example of two simple decorators (one a decorator factory actually) which will work in TypeScript >=5.0.0 (TS playground) without experimentalDecorators:

// this is a sketch of Stencil's `Prop()` decorator
function Prop<PropVal> (options: any) {
  return function<T>(_target: undefined, context: ClassFieldDecoratorContext<T, PropVal>) {
    console.log(`Setting up Prop for ${String(context.name)} with options ${JSON.stringify(options)}`)
  };
};

function log<T>(_target: undefined, context: ClassFieldDecoratorContext<T, string>) {
  return function(this: T, value: string) {
    console.log(`[LOG]::${value}`);
    return value
  }
}

class MyComponent {
  @Prop({ myOption: true })
  @log
  decoratedProp: string = "foobar";
}

const component = new MyComponent();

This compiles to quite a bit more code than TS Decorators with experimentalDecorators did, check out the TS playground linked above to see. It is helpful to run that code in the playground as well to get a sense of when the decorator is actually called.

Although as of TypeScript 5.0.0 it will be possible to use decorators with the syntax and semantics described in the TC39 proposal this does not mean that TypeScript will necessarily include any decorators in the JS that it emits. As of this writing, TypeScript will only include the @decoratorname syntax in the output if the compilation target is set to ESNext and the useDefineForClassFields option is set to true. You can see an example of what the output looks like here: TS Playground. This is all quite sensible, as browsers do not yet support decorators.

How are Stencil’s built-in decorators (Prop, Watch, etc) implemented?

Stencil’s has built-in decorators like @Prop, @Watch, and @Component which are core pillars of the DSL that allows Stencil users to add props, reactive data, and more to their components. Something which is not obvious if you are just trying to use Stencil to author a component, however, is that the behavior, side effects, options, etc which are specified by using these decorators are not actually implemented using actual TS Decorators or ES Decorators.

Instead, the Stencil compiler uses TypeScript transforms to recognize and transform these special, built-in decorators at compile-time. Metadata is added to the compiled Stencil component and the syntax tree nodes corresponding to the built-in decorators are removed. The code for this is in src/compiler/transformers/decorators-to-static/.

This process looks something like this:

Essentially, although it looks like you’re using a decorator on a syntax level when you author a Stencil component, in reality, behind the scenes, this gets transformed into properties on the compiled component class which hold necessary metadata (like prop names, default values, etc) and then code in the Stencil runtime takes that metadata and actually puts values where they are supposed to go, sets up watchers, and so on. This is a bit confusing, but it does mean that as far as Stencil Decorators are concerned, there is no change necessary to upgrade to TypeScript 5.0. The same, however, cannot be said for support for user-defined decorators in Stencil.

Stencil’s support for custom decorators

While we support a small set of Stencil Decorators which are implemented through a combination of AoT transformation and runtime code, we have not historically endorsed or recommended component authors implementing their own custom functionality using decorators. However, this did work in earlier versions of Stencil, but was inadvertently broken in v2.19.0.

Overall, my impression is that custom decorators are used by a small minority of Stencil users. However, supporting them fully in the future is consistent with Stencil’s approach of using widespread frontend standards such as TypeScript, Sass, etc. Users expect that, since it looks like a TypeScript file, they should have access to the full range of expression of TypeScript while they work on their components.

In versions <=2.18.1

In Stencil version 2.18.1 and earlier custom decorators worked just fine as long as users did not set the useDefineForClassFields option in their TypeScript configuration.

In versions 2.19.0+

In 2.19.0 we released a change which fixed an issue with Stencil Decorators and the useDefineForClassFields TypeScript option. Essentially, the useDefineForClassFields option causes TypeScript to emit class fields in JS, like so:

class Foo {
  bar = "baz";
}

whereas previously it would add statements to initialize the property to the class’ constructor, like so:

class Foo {
  constructor() {
    this.foo = "baz";
  }
}

The problem was that the Stencil runtime code for State and Prop depends on being able to use Object.defineProperty in order to set, in this case, the foo property on the prototype for the class. This works fine if the field’s value is initialized in the constructor, but it does not work if it’s initialized as a field. So the change we made detected when a field was decorated with State or Prop and, if so, it deleted the field from the body of the class and manually added statements to the class’ constructor to properly initialize it. This allows Stencil Decorators to work correctly with useDefineForClassFields set to either true or false, but it had an unintended side effect of breaking support for custom decorators. Now we need to make some sort of change in order to restore support for using custom decorators in Stencil.

Recommendations for decorator support in Stencil

The inclusion of ES Decorators in TypeScript 5.0 means that, as we look to upgrade, we need to evaluate the stance that Stencil should take. There are essentially four possible scenarios that I see for how we can handle the TypeScript 5.0 upgrade in relation to decorators:

Possible scenarios

These four different scenarios are the different ways that I see that we could handle support for Stencil Decorators, the upgrade to TypeScript 5.0, and support for custom decorators (whether TS Decorators or ES Decorators).

Status quo

Custom decorators (TS Decorators and ES Decorators, although the latter at this point only hypothetically) are currently broken / unsupported in Stencil, while Stencil Decorators work fine. A viable path to TS 5.0 (and, in fact, the shortest path) would be to continue to punt on reconciling the changes made in 2.19.0 with non-Stencil Decorators. Since we remove the concrete syntax tree nodes corresponding to Stencil Decorators during compilation, the change in handling the decorator syntax @decoratorname in the TypeScript compiler won’t matter, and we could continue to support the same developer experience we do currently.

Advantages:

Disadvantages:

Custom TS Decorators or ES Decorators alongside Stencil decorators

A possible scenario would be to maintain Stencil Decorators as they are but additionally implement support for custom decorators through further custom transformers which would recognize the usage of a custom (i.e. non-Stencil) decorator and make appropriate changes in order to call it with the correct arguments.

As discussed above, due to the changes implemented in 2.19.0 to support the useDefineForClassFields option we need to do additional transformation work in order to restore support for custom decorators. I recently put together a proof-of-concept of this approach for TS Decorators which explains some of how this works and why it is necessary. Unfortunately, any approach to doing this is likely to be at least somewhat brittle and will, I believe, have to depend on non-public TS apis (as the PoC does).

Advantages:

Disadvantages:

Support custom TS Decorators and custom ES Decorators, maintain Stencil decorators

Similar to the scenario above, we could keep Stencil Decorators as they are (implemented with TS transformers and custom runtime code) and add further transformers which detect whether the experimentalDecorators option is set to true, and, if so, transformers for custom TS Decorators would be used, or if false, transformers for custom ES Decorators would be used. This is obviously the most complex option possible, but it would allow us to support custom decorators, according to the user’s preference (TS Decorators or ES Decorators), while avoiding a rewrite of the Stencil Decorators.

Advantages:

Disadvantages:

All in on ES Decorators

As part of the TypeScript 5.0 upgrade, or shortly thereafter, we port all the Stencil Decorators to ES Decorators. By doing so, we could remove all the code related to transforming Stencil Decorators, and the functionality currently provided by the hybrid approach of some AoT transformation work and some runtime work would be provided by an ES Decorator directly. In a sense, this would be converting Stencil’s built-in decorators from something implemented by the Stencil compiler into something Stencil users import from a library. Removing the decorators-to-static transformers would also clear the way for Stencil users to create their own decorators, since our custom decorator implementation would no longer be messing around with the TypeScript syntax tree and stepping on their toes.

One caveat with this approach is that because ES Decorators and TS Decorators cannot be mixed3 we could have to enforce that Stencil users do not have experimentalDecorators set in their tsconfig.json.

Advantages

Disadvantages:

My recommendation

I believe that the best option is to go ‘all in’ on ES Decorators. That way we support one single decorator standard, and that standard happens to be the most modern and best standardized one and the one which will eventually run in browsers. This approach also reduces the distance between Stencil’s implicitly-defined dialect of TypeScript and TypeScript itself, and would allow us to remove a good deal of complex, difficult-to-debug code from the codebase. We would restore custom decorator support not by implementing complex code transforms to jerry-rig it in, but simply by doing things ‘the right way.’

This approach is not without risks. The runtime code for Prop, State, Watch, etc is complex and not well understood (I believe) by the team at present, and much of it is undocumented.

These risks could be mitigated by first upgrading to TypeScript 5.0 and then converting Stencil Decorators to ES Decorators one at a time. The lessons learned from the first one to be completed would likely clarify the viability of the path overall.

Additionally, a feature flag could be used to gate the new ES Decorator-based State, Prop and so on while the current, working functionality was left in place. We could then deprecate the ‘legacy’ Stencil decorators at some point in the future when the ES Decorator variants are fully stabilized.

A gradual approach would allow for custom decorators as soon as State and Prop have been converted to ES Decorators, since the problem with custom decorators only exists on those fields.

The current implementation of decorators in Stencil is functional but showing its age. TypeScript 5.0 offers us an opportunity to reimagine how we implement Stencil’s component DSL in a ‘ES-native’ way, getting rid of custom, one-off solutions in favor of leveraging standard language features and tools.


  1. See the Introduction to the standard for more on what this means↩︎

  2. I believe that the fact that Stencil’s AoT compilation means that component authors are actually writing their components in a different, albeit closely related, programming language is probably not widely understood, and is an ongoing liability in terms of bugs and issues when the behavior that we implement does not match the user’s expectations.↩︎

  3. I believe this is the case, although I have not seen anything specifically saying so. Given that TS Decorators and ES Decorators have different type signatures and semantics I cannot see how they could be used in the same project.↩︎