Using modern decorators in TypeScript

Using Modern Decorators TypeScript

TypeScript has indeed become one of the most popular programming languages, and its growth is remarkable. Its popularity is due in part to its ability to provide type-checking and other enhancements that make code more reliable and easier to maintain. However, one of the challenges that TypeScript faces is that it often introduces new features that are not yet part of the ECMAScript standard that JavaScript relies on.

This can lead to a situation where developers using TypeScript are able to use features that are not yet available in JavaScript, which can create compatibility issues with other libraries and tools that rely on the ECMAScript standard. However, as the popularity of TypeScript continues to grow, it's likely that more and more of its features will be adopted into the ECMAScript standard, making it easier for developers to write code that is compatible with other tools and libraries.

One example is the reintroduction of decorators in the soon-to-be-released TypeScript 5.0; decorators is a meta-programming technique that can be found in other programming languages. If you’re an application developer or library author who is interested in using the new official TypeScript decorators, you’ll want to adopt the new syntax and understand the differences between the old and new feature sets. The API differences are extensive and it is unlikely that old decorators will work with the new syntax out of the box.

In this article, we’ll review the history of using decorators in TypeScript, discuss the benefits associated with decorators in TypeScript 5.0, walk through a demo using modern decorators, and explore how to refactor existing decorators.

N.B., all the APIs have changed extensively in TypeScript 5.0; for this article, we’ll focus on class method decorators

History of TypeScript decorators

Decorators is a feature that enables developers to reduce boilerplate by quickly adding functionality to classes, class properties, and class methods. When TypeScript first introduced decorators it did not follow the ECMAScript specification. This wasn’t great for developers, since ideally emitted code from any JavaScript compiler should comply with web standards!

Using decorators required setting an --experimentalDecorators experimental compiler flag. Several popular TypeScript libraries, such as type-graphql and inversify, rely on this implementation.

Here’s an example of a simple class method decorator, demonstrating the enhanced ergonomics of the new syntax:

function debugMethod(_target: unknown, memberName: string, propertyDescriptor: PropertyDescriptor) {
  return {
    get() {
      const wrapperFunction = (...arguments_: unknown[]) => {
        const now = new Date(Date.now());
        console.log('start time', now.toISOString());
        propertyDescriptor.value.apply(this, arguments_);
        const end = new Date(Date.now());
        console.log('end time', end.toISOString());
      };
      Object.defineProperty(this, memberName, {
        value: wrapperFunction,
        configurable: true,
        writable: true,
      });
      return wrapperFunction;
    },
  };
}
class ComplexClass {
  @debugMethod
  public complexMethod(a: number): void {
    console.log("DOING COMPLEX STUFF!");
  }
}

In the above code, we can see that the debugMethod decorator overrides the class method property using Object.defineProperty, but in general, the code isn’t easy to follow. Also, the arguments are not type-safe, which limits our safety inside the wrapperFunction. Additionally, the compiler will not fail if this decorator is used on an invalid use case, such as a class property.

We could use TypeScript generics to try to achieve type safety, but TypeScript does not infer generic types and this makes them a pain to consume. Thus, writing complex decorators is difficult due to the unknown values users can input into them.

The modern version of decorators, which will be officially rolled out in TypeScript 5.0, no longer requires a compiler flag and follows the official ECMAScript Stage-3 proposal. Alongside a stable implementation that follows ECMAScript standards, decorators now work seamlessly with the TypeScript type system, enabling more enhanced functionality than the original version.

With the new implementation of decorators in TypeScript 5.0, these aspects are greatly improved. Let’s take a look.

Decorators in TypeScript 5.0

TypeScript 5.0 offers better ergonomics, improved type safety, and more. Here’s a similar example of a TypeScript 5.0 decorator that overrides a class method:

function debugMethod(originalMethod: any, _context: any) {
  function replacementMethod(this: any, ...args: any[]) {
    const now = new Date(Date.now());
    console.log('start time', now.toISOString());
    const result = originalMethod.call(this, ...args);
    const end = new Date(Date.now());
    console.log('end time', end.toISOString());
    return result;
  }
  return replacementMethod;
}
class ComplexClass {
  @debugMethod
  complexMethod(a: number): void {
    console.log("DOING STUFF!");
  }
}

N.B., to try out TypeScript in an online playground, just switch the version to “nightly” or “5.0”

With the new implementation, simply returning the function can now replace it; there’s no need for the Object.defineProperty. This makes decorators easier to implement and understand. Alongside this improvement, let’s make it completely type-safe:

function debugMethod<TThis, TArgs extends [string, number], TReturn extends number>(
  originalMethod: Function,
  context: ClassMethodDecoratorContext<TThis, (this: TThis, ...args: TArgs) => TReturn>
) {
  function replacementMethod(this: TThis, a: TArgs[0], b: TArgs[1]): TReturn {
    const now = new Date(Date.now());
    console.log('start time', now.toISOString());
    const result = originalMethod.call(this, a, b);
    const end = new Date(Date.now());
    console.log('end time', end.toISOString());
    return result;
  }
  return replacementMethod;
}

The decorators function in TypeScript 5.0 is greatly improved and now supports the following:

  • Using generics to type a method’s arguments and return a value; the method must accept a string and a number, TArgs, and return a number, TReturn
  • Typing the originalMethod as a Function
  • Using the ClassMethodDecoratorContext inbuilt helper type; this exists for all decorator types

We can test to see if our decorator is truly type-safe by inspecting errors when it is used incorrectly:

TypeScript 5.0 Incorrectly Typed Arguments

Now, let’s look at an actual use case for the new TypeScript 5.0 decorators.

Decorator factory demo

We can use the type safety available in the TypeScript 5.0 decorators to create functions that return a decorator, otherwise known as a decorator factory. Decorator factories allow us to customize the behavior of our decorators by passing some parameters in the factory.

For our demo, we’ll create a decorator factory that changes the class method argument based on its own arguments. This is possible with a TypeScript type ternary operator. Our example is inspired by REST API frameworks like NestJS.

We’ll call our module rest-framework. Let’s start by creating a blank TypeScript project using ts-node:

$ mkdir rest-framework
$ cd rest-framework
$ npm init -y
$ npm install -D typescript@5.0.4 @types/node ts-node
$ touch index.ts
$ echo "console.log('Hello, world!');" > index.ts

Next, we’ll define the script to build and run the project in package.json:

{
  // ...
  "scripts": {
    "build": "tsc",
    "start": "ts-node index.ts"
  }
}

Let’s run npm start to see it in action:

$ npm start
Hello, world!

Now, let’s define our types:

interface RouteOptionsAuthEnabled {
  auth: true;
}
interface RouteOptionsAuthDisabled {
  auth: false;
}
type RouteArguments = [string] | [];
type RouteDecorator<TThis, TArgs extends RouteArguments> = (
  originalMethod: Function,
  context: ClassMethodDecoratorContext<
    TThis,
    (this: TThis, ...args: TArgs) => string
  >
) => void;

Next, let’s define the factory decorator:

function Route<
  TThis,
  // The user can enable or disable auth
  TOptions extends RouteOptionsAuthEnabled | RouteOptionsAuthDisabled
>(
  options: TOptions
): RouteDecorator<
  TThis,
  // Do not accept a function that uses a string for an argument if auth is disabled
  TOptions extends RouteOptionsAuthEnabled ? [string] : []
> {
  return <TThis>(
    target: (
      this: TThis,
      ...args: TOptions extends RouteOptionsAuthEnabled ? [string] : []
    ) => string,
    context: ClassMethodDecoratorContext<
      TThis,
      (
        this: TThis,
        ...args: TOptions extends RouteOptionsAuthEnabled ? [string] : []
      ) => string
    >
  ) => {};
}

Now we have a route decorator that changes the class method parameter types depending on the user’s options.

Let’s create an example Route class to act as our test case:

class Controller {
  @Route({ auth: true })
  get(authHeaderValue: string): string {
    console.log("get http method handled!");
    return "response";
  }
  @Route({ auth: false })
  post(): string {
    console.log("post http method handled!");
    return "response";
  }
}

We can see that TypeScript fails to compile if we try to use authHeaderValue in the post route:

AuthHeaderValue Post Route TypeScript Compilation Failure

The decorator factory use case is a simple example, but it demonstrates the power of what type-safe decorators can do.

Refactoring existing decorators

If you’re using an existing TypeScript decorator, you’ll want to refactor to use the API and take advantage of its many benefits. Basic decorators can be easily refactored to the new ones, but the difference is substantial enough that advanced use cases will take effort.

For best results, follow these steps to refactor existing decorators:

  • Write unit tests for your decorators
  • Remove or falsify the experimentalDecorators TypeScript compiler flags
  • Read this extensive summary of how the new proposal works
  • Understand the limitations of modern decorators (we’ll cover this in more detail later in this article)
  • Rewrite decorators using no types and use any in place of all types
  • Make sure unit tests pass
  • Add types

Understanding the limitations of modern decorators

The modern decorator implementation is great news for TypeScript developers, but there are notable features missing. First, there’s no support for decorating method parameters. This is within the spec of the proposal, so hopefully it will be included in the final spec. Its omission is notable because popular libraries, like type-graphql, utilize this in important ways, such as writing resolvers:

@Query(returns => Recipe)
async recipe(@Arg("recipeId") recipeId: string) {
  return this.recipeRepository.findOneById(recipeId);
}

Second, TypeScript 5.0 cannot emit decorator metadata. Subsequently, it doesn’t integrate with the Reflect API and won’t work with the reflect-metadata npm package.

Third, the --emitDecoratorMetadata flag, which was previously used to access and modify metadata for given decorators, is no longer supported. Unfortunately, there’s no real way to achieve the same functionality by getting the metadata at runtime. There are some cases that can be refactored. For example, let’s define a decorator that validates a function’s parameter types at runtime:

function validateParameterType(target: any, propertyKey: string | symbol): void {
  const methodParameterTypes: (string | unknown)[] =
    Reflect.getMetadata("design:paramtypes", target, propertyKey) ?? [];
  const firstParameterType = methodParameterTypes[0];
  if (typeof firstParameterType !== "string") {
    throw new TypeError("First parameter must be a string");
  }
}

We can achieve similar functionality with the improved type safety provided by TypeScript 5.0. We simply add the arguments of the method we are decorating, like so:

function debugMethod<TThis, TArgs extends [string], TReturn>(
) {
...

In theory, we could use this approach to refactor decorators that depend on getting types from Reflect for design:typedesign:paramtypes, and design:returntype. This is a different way to write decorators; it is not a simple refactor because it requires using TypeScript type inference to refactor how types are acquired and validated.

Conclusion

The new decorator implementation in TypeScript 5.0 follows the official ECMAScript Stage-3 proposal and is now type-safe, making it easier to implement and understand. However, some notable features are missing, such as support for decorating method parameters and the ability to emit decorator metadata.

Basic decorators can be easily refactored to the TypeScript 5.0 version, but advanced use cases will require more effort. Developers can refactor existing decorators to use the new API and take advantage of the associated benefits. They can be less dependent on external libraries and are less likely to refactor code in the future. These changes to TypeScript’s implementation of decorators are a benefit to the broader ecosystem, but community adoption could take some time.

No comments:

Post a Comment