Decorators
Generally, decorators are a way to implement repetitive aspects and apply them in multiple places. To help you understand, imagine that the name “decorator” is derived from the classic “Gang-of-Four” decorator pattern. In contrast to the proxy pattern, this isn’t about controlling access to a method, but extending the method’s behavior with added functionality. This can refer to both calling the method (a cache, for instance) and its return. One example is enriching the return data with additional information. This can come from a separate method call or even an HTTP API call.
Implementing a decorator is based on an interceptor. Therefore, (method/class) name, visibilities, and arguments are also available. You can also store initialization logic for the decorated field.
However, changing or restricting the types of method signatures or classes using decorators isn’t possible yet. To make this happen, there are open tickets in TypeScript.
In general, TypeScript 5 implements Stage 3 of the ECMAScript Decorators proposal. This is the proposal’s current version. Unfortunately, this ECMAScript specification is slightly different from the previously usable decorators form in TypeScript, which is activated with “–experimentalDecorators”. So even in TypeScript itself, the new decorators differ from the previous ones. This leads to the fact that some well-known projects and libraries, like Angular or type-graphql, won’t switch to the new specification for the time being. This is possible because the experimental decorators can still be activated like before.
iJS Newsletter
Keep up with JavaScript’s latest news!
Decorators can be used to customize or extend the behavior of classes and their methods, set/get accessors, and properties.
Listing 1: Example class Animal with the method “eat()”.
class Animal {
eat(): void {
console.log(`Any logic here, e.g. XHR calls`);
}
}
const p = new Animal("Dog");
p.eat();
As an example, we want to implement the “logging” cross-cutting connector for the “eat()” method for the “Animal” class in Listing 1 to facilitate debugging. For instance, if we want it to be logged when the method starts and when the method returns, then the logging code must be inserted manually “at the beginning” and “at the end” of the method without decorators. This is prone to errors and won’t be good to reuse if logging is enabled in other places too. It’s better to connect logging as a cross-cutting concern with a separate, reusable mechanism.
Decorators can be used for this. Under the hood, a decorator looks something like the one in Listing 2 for the “logged” decorator. Actually, decorators are simple functions that are passed the function (or class/parameter) to decorate. Here, this is the function parameter “originalMethod”. Both the pass values of the decorated function and the return value are typed generically in this example. With the “This” type parameter, the type of the outer class can also be defined or restricted. The second parameter is a context object containing information about the decorated method, for instance, whether it is declared as “static”, is a “private” field, or even its name. This information can be used in the decorator. In this case, the name of the decorated method is used to indicate in the log when the decorated method (“originalMethod”) was called and when it terminated
Listing 2: Example of a decorator that logs method calls
function logged<This, Args extends any[], Return>(
originalMethod: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This>
) {
const methodName = String(context.name);
function replacementMethod(this: This, ...args: Args): Return {
console.log(`Vor Methodenaufruf von '${methodName}'`)
const result = originalMethod.call(this, ...args);
console.log(`Nach Methodenaufruf von '${methodName}'`)
return result;
}
return replacementMethod;
}
A decorator is always marked with “@” when used in TypeScript, as seen in Listing 3.
Listing 3: Using the logging decorator and beacon decorator with output from the logging decorator
class Animal {
species: string;
constructor(species: string) {
this.species = species;
}
@beacon(1000)
@logged
eat(): void {
console.log(`Any logic here, e.g. XHR calls`);
}
}
const p = new Animal("Dog");
p.eat();
// Output(without “beacon()”-Decorator):
// Before calling the method 'eat'
// Any logic here, e.g. XHR calls.
// After method call of 'eat
EVERYTHING AROUND ANGULAR
The iJS Angular track
You can also give a decorator additional parameters for configuration. This can be seen in Listing 3 for the “beacon” decorator, which can be given a number. In the example, it is given the number 1000. Technically, this is not a decorator directly, but is a decorator factory. The factory is passed the value 1000 and returns the actual, preconfigured decorator. Implementing the “beacon” decorator factory is shown in Listing 4. The beacon decorator makes sure that the decorated function is called in a regular rhythm. In the example, this is every 1000 milliseconds. Another feature of the “ClassMethodDecoratorContext” is used for this. The “addInitializer()” function can be used to add an initializer in the form of a callback to the decorated function (or even the decorated property, class, etc.). The initializer is called before the class or the respective class member is initialized.
In the example in Listing 4, an interval is then simply started, calling the function regularly. Listing 4 also shows that restrictions are possible with the generic type parameters. Since the beacon decorator can only be used meaningfully if the method has no passing parameters, no “Args” parameters are defined. In contrast to the “logged” decorator; the return type of the method that will be decorated is also restricted to “void”.
An important side aspect of decorators is the order in which they are applied. They are always applied from “bottom” to “top”. In the example in Listing 3, this means that the “logged” decorator is applied first, and then the “beacon” decorator. This results in both the method output and the “logged” decorator output appearing after each beacon interval.
Listing 4: Example implementation of the Beacon Decorator
function beacon(milliseconds: number) {
return function beaconImpl<This>(
originalMethod: (this: This) => void,
context: ClassMethodDecoratorContext<This, (this: This) => void>
) {
if (milliseconds) {
context.addInitializer(function () {
setInterval(() => originalMethod.apply(this), milliseconds);
});
}
return originalMethod;
}
}
In addition to the features described above that went live in TypeScript 5, other functionalities (some of which have already been implemented with “–experimentalDecorators”) are not rolled out yet. For example, metadata generated by the decorator cannot be emitted. This was possible under the experimental decorators with the additional flag “–emitDecoratorMetadata” and was used by frameworks like Angular to enrich decorated elements with additional information. This mechanism was used to link Angular components to their template, but this is now handled with an Angular-internal mechanism in conjunction with decorators. The actual TypeScript implementation of the metadata emit is still hindered because the associated ECMAScript specification has only reached Stage 2, and so API changes may still occur.
Another missing functionality are parameter decorators. These are decorators with which method or constructor parameters can be decorated. This is a feature used by libraries like Angular, Nest.js, or type-graphql. As of shortly before the TypeScript 5 release, there are no separate proposals for this and no implementation in TypeScript. Because of this, Angular will not yet switch to the new decorators, but will continue using the experimental decorators, since the dependency injection feature is built on this pattern.
Additionally, there’s the idea of being able to change the class type or method signatures with decorators to enable requirements like validation using decorators, for example. However, there is still no concrete planning for this feature yet. But there are various ideas and discussions about this topic on GitHub, where everyone is welcome to participate.
JSDoc innovations
JSDoc is comparable to JavaDoc and is the standard method for software documentation in the JavaScript area. TypeScript’s support for JSDoc brings the huge advantage that tools like IDEs can also handle it by default.
Integrating JSDoc with TypeScript works well. In fact, it works so well that TypeScript can use it to do type checking in normal JavaScript files. For example, in the JavaScript code in Listing 5, JavaScript can tell that “demoValue” needs to be a string based on the “@type” specification. Therefore, assigning the number “5” to the “demoValue” with TypeScript will trigger a compiler error. In TypeScript 5, even more complex constructs can be implemented with these type comments. For example, as shown in Listing 6, method overloading can now take place. However, this does not overload the complete method, but, as in TypeScript, only the method signature in the JSDoc comments. The “@overload” tag is used to mark these overloads.
In the example in Listing 6, we have a method “calcShipping” that calculates delivery costs in a store. The method is overloaded so that it can only be passed the delivery options “STANDARD” or “EXPRESS” (first @overload), or the order amount as the first parameter and the delivery options as the second parameter (second @overload). After that comes the method’s “real” signature, which just summarizes the overloads mentioned above.
Listing 5:Type check in JavaScript via JSDoc information
// @ts-check
/** @type {string} */
var demoValue;
// demoValue = 5; Would trigger compile error
Listing 6: Example of overload definition using TypeScript 5 JSDoc in JavaScript
// @ts-check
/**
* @overload
* @param {'STANDARD'|'EXPRESS'} versandOrPrice
* @return {number}
*/
/**
* @overload
* @param {number} versandOrPrice
* @param {'STANDARD'|'EXPRESS'} [versandArt]
* @return {number}
*/
/**
* @param {'STANDARD'|'EXPRESS' | number} versandOrPrice
* @param {'STANDARD'|'EXPRESS'} [versandArt]
*/
function calcShipping(versandOrPrice, versandArt) {
const art = typeof versandOrPrice === 'number'? versandArt: versandOrPrice
if (art === 'EXPRESS') {
return 9;
}
if (typeof versandOrPrice === 'number' && versandOrPrice > 50) {
return 0;
}
return 6;
}
calcShipping(10);
calcShipping(10, 'EXPRESS');
//calcShipping('STANDARD', 'EXPRESS'); Produces a compiler error
The “satisfies” operator, newly introduced in TypeScript 4.9, can also be used in JSDoc with version 5. The “satisfies” operator can be used to make sure that a given expression conforms to a type definition without performing a true type assertion with “as”. This is shown in Listing 7. Various “shapes” are defined as string literal types and a “record” containing the various shapes as keys. Each key contains the respective number of shapes, i.e. how often the respective shape occurs in a graphic, for example. This number can be specified either as “number” or the number as a word. The satisfies operator can now be used to check if the “shapesInGraphic” object complies with the specified record type. In the example in Listing 7, an error would be thrown because the word “triangle” is misspelled. Despite this check, however, the object retains its exact type. TypeScript knows that “circle” contains a “number” and “square” contains a “string”. This can be used throughout the code. For example, here the “toUpperCase()” method is called on the “square”.
Listing 7: Example of using the TypeScript Satisfies operator in the context of records and string literal types
type Shapes = "circle" | "square" | "triangle";
const shapesInGraphic = {
"circle": 4,
"square": 'one',
"tirangle": 3
// ^ Typo: "tirangle" does not exist in the shapes (recognized by the compiler!).)
} satisfies Record<Shapes, number | string>;
shapesInGraphic.square.toUpperCase()
// Is allowed because the exact type of shapesInGraphic is preserved
In TypeScript 5, this now works for JavaScript files with corresponding JSDoc comments. Listing 8 shows an example of the TypeScript example from Listing 7 implemented in JavaScript, using the “@satisfies” JSDoc tag.
Listing 8: JavaScript equivalent to Listing 7, the type information is expressed by JSDoc
/**
* @typedef {"circle" | "square" | "triangle"} Shapes
*/
/**
* @satisfies {Record<Shapes, number | string>}
*/
const shapesInGraphic = {
"circle": 4,
"square": 'one',
"tirangle": 3
// ^ Typo: "tirangle" does not exist in the shapes (recognized by the compiler!).)
};
shapesInGraphic.square.toUpperCase()
Other innovations
Of course, besides the new features described above, there are many other new features in TypeScript 5. Here’s a brief overview of some of the new features:
- The ECMAScript language standard ES3 has been deprecated as a compilation target.
- TypeScript now allows code completion to generate the various “case:” cases in its switch case statement when switching over restricted value sets like enums or literal types
- TypeScript 4.7 introduced a stricter module resolution. Because this sometimes didn’t fit the behavior of module bundlers like Webpack, TypeScript 5 now includes a new config option (“moduleResolution”: “bundler”) for these bundlers. This option is only available in combination with the “esnext” module system (“module”: “esnext”). There are now also some more flags to help configure the module resolution in more detail if needed. These can be traced in the official TypeScript 5 documentation.
- It’s now possible to explicitly export only type information with barrel export. The following syntax can be used for this: export type * as shapes from “./shapes”;
- Conversely, the optimization of imports has also been improved, especially with type-only imports. These types of imports may be needed if you aren’t compiling directly with TypeScript. In these cases, with the new “–verbatimModuleSyntax” option, it’s now clearer that type-only imports can be optimized away, while “classic” imports are preserved when compiling.
Breaking changes
Of course, TypeScript version 5 introduces some breaking changes. For example, there was an improvement in the handling of enums, which ensures that all enum values also get their own type (all enums are now “union enums”). This solves some open bugs in TypeScript related to special enums whose values are calculated dynamically. The type checks for these enums also got better/stricter, so it is ultimately a breaking change.
Another breaking change based on improved type checks relates to automatic type conversions in mathematical comparisons. Previously, strings were simply converted to numbers automatically in these instances. Since this is a very implicit and potentially undesirable behavior, errors are now output in these cases (see Listing 9). If you still want to convert a string to a number, the conversion must now be done explicitly, e.g. with the “+” operator (“return +value > 42;”).
Listing 9: Implicit type conversion from String to Number
function func(value: number | string) {
return value > 42; // Compiler error
}
Node compatibility
TypeScript 5 itself requires a more recent Node version. It supports Node from version 10 upwards. The reason for this is that TypeScript 5 targets ECMAScript 2018. However, since the smallest LTS version supported by Node.js is currently Node 14, this restriction shouldn’t cause any problems.
Optimizations for developers and users
TypeScript 5 also brings some improvements in its results. Depending on the project in question, TypeScript 5 can improve build times by up to 81%. As a result, you may receive a build about a quarter faster. These improvements are achieved with a clever usage of caching and switching from namespaces to modules.
The TypeScript package also shrunk to roughly half its size. This is not only because of internal optimizations, but also because aspects are now implemented with modern ECMAScript means and no longer need to be “re-implemented” by TypeScript. All in all, the savings of about 25 MB is considerable, but not significant for your daily development.
Another practical improvement, especially for development: now several configuration files can be specified in the TSConfig, from which the configuration will inherit. Therefore, a configuration can be indicated for the development in general and another for the local environment. This is implemented by the fact that with “extends”, an array is indicated, as shown in the example in Listing 10.
Listing 10: tsconfig.json example with Multiple Inheritance
/**
* @
// tsconfig.json
{
"compilerOptions": {
"extends": ["./tsconfig-base.json", "./tsconfig-local.json"]
},
"files": ["./index.ts"]
}
Conclusion
TypeScript 5 on its own doesn’t bring any earth-shattering changes that makes an immediate upgrade necessary. However, the major release is used to implement some breaking changes that harmonize with upcoming ECMAScript features. Especially the new decorators changes will lead to some adjustments for framework developers who have implemented basic functionality with them. For example, the Angular project announced that it will not implement the new decorators for now. This is because the changes are at odds with functionality previously provided by experimental decorators. For instance, the previous dependency injection cannot be implemented with them.
Solutions will be found in the long run. For example, Angular already provides an inject function, so there aren’t any fundamental concerns here for the future.
TypeScript 5 presents as a solid release with a focus on optimizations. Developers who are not impacted by the breaking changes will gain immediate productivity and optimized code for operations. After updating, this benefits developers and users too.