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.
A few definitions that I’m using throughout this document to distinguish between a few closely related things.
experimentalDecorators
flag is set.@Watch
, @Prop
, @State
and
friends).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: {
: string;
kind: string | symbol;
name: {
accessget?(): unknown;
set?(value: unknown): void;
;
}?: boolean;
private?: boolean;
static?(initializer: () => void): void;
addInitializer=> 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 {
= false;
@reactive accessor myBool }
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!
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.
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.
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()
@: string = "foobar";
decoratedProp }
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 {
= "foobar";
decoratedProp
}__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.
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')
@= "hey!";
myField }
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 */ )
@: string = "hey";
foo }
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.
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: string = "foobar";
decoratedProp
}
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.
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.
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 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 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 {
= "baz";
bar }
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.
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:
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).
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:
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:
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:
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:
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.
See the Introduction to the standard for more on what this means↩︎
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.↩︎
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.↩︎