Implement the Stage 3 Decorators Proposal by rbuckton · Pull Request #50820 · microsoft/TypeScript (original) (raw)

This implements support for the Stage 3 Decorators proposal targeting ESNext through ES5 (except where it depends on functionality not available in a specific target, such as WeakMaps for down-level private names).

The following items are not currently supported:

With that out of the way, the following items are what is supported, or is new or changed for Decorators support in the Stage 3 proposal:

Type Checking

When a decorator is applied to a class or class member, we check that the decorator can be invoked with the appropriate target and decorator context, and that its return value is consistent with its target. To do this, we check the decorator against a synthetic call signature, not unlike the following:

type SyntheticDecorator<T, C, R> = (target: T, context: C) => R | void;

The types we use for T, C, and R depend on the target of the decorator:

Method Decorators

class MyClass { m(): void { ... } }

A method decorator applied to m(): void would use the types

type T = (this: MyClass) => void; type C = & ClassMethodDecoratorContext<MyClass, (this: MyClass) => void> & { name: "m", private: false, static: false }; type R = (this: MyClass) => void;

resulting in a call signature like

type ExpectedSignature = ( target: (this: MyClass) => void, context: & ClassMethodDecoratorContext<MyClass, (this: MyClass) => void> & { name: "m", private: false, static: false }, ) => ((this: MyClass) => void) | void;

Here, we specify a target type (T) of (this: MyClass) => void. We don't normally traffic around the this type for methods, but in this case it is important that we do. When a decorator replaces a method, it is fairly common to invoke the method you are replacing:

function log<T, A extends any[], R>( target: (this: T, ...args: A) => R, context: ClassMethodDecoratorContext<T, (this: T, ...args: A) => R> ) { return function (this: T, ...args: A): R { console.log(${context.name.toString()}: enter); try { // need the appropriate this return target.call(this, ...args); } finally { console.log(${context.name.toString()}: exit); } }; }

You may also notice that we intersect a common context type, in this case ClassMethodDecoratorContext, with a type literal. This type literal contains information specific to the member, allowing you to write decorators that are restricted to members with a certain name, placement, or accessibility. For example, you may have a decorator that is intended to only be used on the Symbol.iterator method

function iteratorWrap<T, V>( target: (this: T) => Iterable, context: ClassMethodDecoratorContext<T, (this: T) => Iterable> & { name: Symbol.iterator } ) { ... }

, or one that is restricted to static fields

function lazyStatic<T, V>( target: undefined, context: ClassFieldDecoratorContext<T, V> & { static: true } ) { ... }

, or one that prohibits usage on private members

function publicOnly( target: unknown, context: ClassMemberDecoratorContext & { private: false } ) { ... }

We've chosen to perform an intersection here rather than add additional type parameters to each *DecoratorContext type for several reasons. The type literal allows for a convenient way to introduce a restriction in your decorator code without needing to fuss over type parameter order. Additionally, in the future we may opt to allow a decorator to replace the type of its decoration target. This means we may need to flow additional type information into the context to support the access property, which acts on the final type of the decorated element. The type literal allows us to be flexible with future changes.

Getter and Setter Decorators

class MyClass { get x(): string { ... } set x(value: string) { ... } }

A getter decorator applied to get x(): string above would have the types

type T = (this: MyClass) => string; type C = ClassGetterDecoratorContext<MyClass, string> & { name: "x", private: false, static: false }; type R = (this: MyClass) => string;

resulting in a call signature like

type ExpectedSignature = ( target: (this: MyClass) => string, context: ClassGetterDecoratorContext<MyClass, string> & { name: "x", private: false, static: false }, ) => ((this: MyClass) => string) | void;

, while a setter decorator applied to set x(value: string) would have the types

type T = (this: MyClass, value: string) => void; type C = ClassSetterDecoratorContext<MyClass, string> { name: "x", private: false, static: false }; type R = (this: MyClass, value: string) => void;

resulting in a call signature like

type ExpectedSignature = ( target: (this: MyClass, value: string) => void, context: ClassSetterDecoratorContext<MyClass, string> & { name: "x", private: false, static: false }, ) => ((this: MyClass, value: string) => void) | void;

Getter and setter decorators in the Stage 3 decorators proposal differ significantly from TypeScript's legacy decorators. Legacy decorators operated on a PropertyDescriptor, giving you access to both the get and set functions as properties of the descriptor. Stage 3 decorators, however, operate directly on the get and set methods themselves.

Field Decorators

class MyClass { #x: string = ...; }

A field decorator applied to a field like #x: string above (i.e., one that does not have a leading accessor keyword) would have the types

type T = undefined; type C = ClassFieldDecoratorContext<MyClass, string> & { name: "#x", private: true, static: false }; type R = (this: MyClass, value: string) => string;

resulting in a call signature like

type ExpectedSignature = ( target: undefined, context: ClassFieldDecoratorContext<MyClass, string> & { name: "#x", private: true, static: false }, ) => ((this: MyClass, value: string) => string) | void;

The target of a field decorator is always undefined, as there is nothing installed on the class or prototype during declaration evaluation. Non-static fields are installed only when an instance is created, while static fields are installed only after all decorators have been evaluated. This means that you cannot replace a field in the same way that you can replace a method or accessor. Instead, you can return an initializer mutator function — a callback that can observe, and potentially replace, the field's initialized value prior to the field being defined on the object:

function addOne( target: undefined, context: ClassFieldDecoratorContext<T, number> ) { return function (this: T, value: number) { return value + 1; }; }

class C { @addOne @addOne x = 1; } new C().x; // 3

This essentially behaves as if the following happened instead:

let f1, f2; class C { static { f1 = addOne(undefined, { ... }); f2 = addOne(undefined, { ... }) } x = f1.call(this, f2.call(this, 1)); }

Auto-Accessor Decorators

Stage 3 decorators introduced a new class element known as an "Auto-Accessor Field". This is a field that is transposed into pair of get/set methods of the same name, backed by a private field. This is not only a convenient way to represent a simple accessor pair, but also helps to avoid issus that occur if a decorator author were to attempt to replace an instance field with an accessor on the prototype, since an ECMAScript instance field would shadow the accessor when it is installed on the instance.

class MyClass { accessor y: number; }

An auto-accessor decorator applied to a field like accessor y: string above would have the types

type T = ClassAccessorDecoratorTarget<MyClass, string>; type C = ClassAccessorDecoratorContext<MyClass, string> & { name: "y", private: false, static: false }; type R = ClassAccessorDecoratorResult<MyClass, string>;

resulting in a call signature like

type ExpectedSignature = ( target: undefined, context: ClassFieldDecoratorContext<MyClass, string> & { name: "#x", private: true, static: false }, ) => ((this: MyClass, value: string) => string) | void;

Note that T in the example above is essentially the same as

type T = { get: (this: MyClass) => string, set: (this: MyClass, value: string) => void };

, while R is essentially the same as

type R = { get?: (this: MyClass) => string, set?: (this: MyClass, value: string) => void, init?: (this: MyClass, value: string) => string };

The return value (R) is designed to permit replacement of the get and set methods, as well as injecting an initializer mutator function like you can with a field.

Class Decorators

class MyClass { m(): void { ... } get x(): string { ... } set x(value: string) { ... } #x: string; accessor y: number; }

A class decorator applied to class MyClass would use the types

type T = typeof MyClass; type C = ClassDecoratorContext & { name: "MyClass" }; type R = typeof MyClass;

resulting in a call signature like

type ExpectedSignature = ( target: typeof MyClass, context: ClassDecoratorContext & { name: "MyClass" } ) => typeof MyClass | void;


Fixes #48885