Exploring the Power of TypeScript Decorators

Karim Adel
Oct 29, 2023
5 min read
post_comment1 Comments
post_like1 Likes

Introduction

TypeScript is a powerful superset of JavaScript that brings static typing and improved tooling to the JavaScript ecosystem. One of TypeScript's most intriguing features is decorators. Decorators are a form of meta programming that allows you to add and modify behavior to classes, methods, properties, and parameters. They provide a way to easily extend and modify the behavior of your code, making it more readable, maintainable, and expressive. In this article, we will delve into the world of TypeScript decorators, exploring their syntax, use cases, and practical applications.

Before start sure you add this configs to ts.config

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
  }
}

Understanding TypeScript Decorators

Decorators are a way to add metadata to your TypeScript code. They are a form of higher-order function that can be applied to classes, methods, properties, and even method parameters. In essence, decorators allow you to wrap or modify the behavior of these code elements. This can be particularly useful for things like logging, validation, and dependency injection.

To use a decorator, you simply prefix the target element (class, method, property, or parameter) with the "@" symbol followed by the decorator name

function simpleDecorator() {
  console.log('---hi I am a decorator---')
}

@simpleDecorator
class A {}

#There are five types of decorators we can use:

  1. Class Decorators
  2. Property Decorators
  3. Accessor Decorators
  4. Factories Decorators
  5. Method Decorators

Exmaple

class User {
  private static userType: string = "guest";
  private email: string;

  public username: string;
  public addressLine1: string = "";
  public addressLine2: string = "";
  public country: string = "";

  constructor(username: string, email: string) {
    this.username = username;
    this.email = email;
  }

  get userType() {
    return User.gen;
  }

  get email() {
    return this.email;
  }

  set email(newEmail: string) {
    this.email = newEmail;
  }

  address(): any {
    return `${this.addressLine1}\n${this.addressLine2}\n${this.country}`;
  }
}

const user = new User("karimAdel88", "karimail88@yahoo.com");
user.addressLine1 = "Cairo";
user.addressLine2 = "Nasr City";

#1. Class Decorators

@frozendecorator can be used to freeze a class in TypeScript. The@frozendecorator is applied to the class constructor function, which then usesObject.freezeto prevent any further modifications to both the constructor function and its prototype.

Let's break down the provided code step by step:

function frozen(target: Function) {
  Object.freeze(target);
  Object.freeze(target.prototype);
}

Here, thefrozendecorator is defined. It takes thetargetparameter, which is the constructor function of the class it is applied to.Object.freeze(target)is used to freeze the class constructor itself, andObject.freeze(target.prototype)is used to freeze the prototype of the class.

@frozen
class User {
  constructor(name: string, email: string) {
    // Constructor logic
  }
}

In this code, the@frozendecorator is applied to theUserclass, which effectively freezes the class constructor and its prototype.

console.log(Object.isFrozen(User)); // true

This line checks if theUserclass constructor is frozen, and it correctly returnstrue, indicating that the class is indeed frozen.

User.addNewProp = "Trying to add new prop value"; // [ERR]: Cannot add property addNewProp, object is not extensible

In this part of the code, you attempt to add a new static propertyaddNewPropto theUserclass, but it results in an error. This error occurs because theUserclass has been frozen by the@frozendecorator, and frozen objects cannot be extended or modified. This behavior ensures that the class remains unaltered after applying the decorator.

console.log(Object.isFrozen(new User("example", "example@example.com"))); // false

In this line, you create a new instance of theUserclass, and then you check if the instance is frozen. The instance is not frozen, which is expected. The@frozendecorator applied to the class targets the class constructor and its prototype, not instances of the class. Therefore, instances of theUserclass are not frozen, and you can still work with them as usual.

This example demonstrates how class decorators can be used to control and restrict modifications to classes in TypeScript, ensuring the immutability and stability of the class definition itself while allowing instances to be created and used without restrictions.

#2. Property Decorators

Property decorators in TypeScript allow you to modify the behavior of class properties. Let's start with a practical example. Consider aUserclass with two properties:usernameandemail. We want to ensure that these properties are required, meaning they must be initialized when a newUserinstance is created. To achieve this, we can define a@requireddecorator.

function required(target: any, key: string) {
  let currentValue = target[key];

  Object.defineProperty(target, key, {
    set: (newValue: string) => {
      if (!newValue) {
        throw new Error(`${key} is required.`);
      }
      currentValue = newValue;
    },
    get: () => currentValue,
  });
}

The@requireddecorator uses theObject.definePropertymethod to redefine the property's setter and getter. If an attempt is made to set the property to a falsy value (e.g., an empty string), it throws an error indicating that the property is required.

Now, when you create aUserinstance without providing values forusernameandemail, the decorator will enforce the requirement:

const p = new User("", "example@example.com"); // [ERR]: username is required.
const u = new User("example", ""); // [ERR]: email is required.

This demonstrates how property decorators can be used to add custom validation rules and behaviors to class properties.

#3. Decorating Property Accessors

Property decorators not only work with regular properties but also with property accessors, including getter and setter methods. When applying a decorator to a property accessor, you have access to the property descriptor, in addition to the target and key.

Consider a scenario where we want to control theenumerableattribute of a property accessor using a@enumerabledecorator:

function enumerable(isEnumerable: boolean) {
  return (target: any, key: string, descriptor: PropertyDescriptor) => {
    descriptor.enumerable = isEnumerable;
    console.log(
      "The enumerable property of this member is set to: " +
        descriptor.enumerable
    );
  };
}

In this example, the@enumerabledecorator sets theenumerableattribute of the property descriptor based on the provided boolean value. This allows us to control whether the property accessor is enumerable or not.

Here's how you can apply the@enumerabledecorator to a property accessor:

class User {
  @enumerable(false)
  get userType(): string {
    return "standard";
  }
}

When you create an instance of theUserclass, the console will print a message indicating the enumerable status of theuserTypeproperty accessor.

#4. Using Decorator Factories

The@enumerabledecorator demonstrates the concept of a decorator factory. Decorator factories are functions that produce decorators with customizable behavior. In this case, theenumerabledecorator factory takes a boolean input, allowing you to control whether a property accessor is enumerable or not.

Decorator factories are commonly used to create reusable and parameterized decorators, making it easy to tailor the behavior of decorators to specific use cases.

#5. Method Decorators

While property decorators modify properties, method decorators work with methods. A common use case for method decorators is to mark methods as deprecated while still allowing them to be used. Let's take a look at a@deprecatedmethod decorator:

function deprecated(target: any, key: string, descriptor: PropertyDescriptor) {
  const originalDef = descriptor.value;

  descriptor.value = function (...args: any[]) {
    console.log(`Warning: ${key}() is deprecated. Use other methods instead.`);
    return originalDef.apply(this, args);
  };
  return descriptor;
}

The@deprecateddecorator logs a warning message to the console and calls the original method when the decorated method is invoked. This allows you to inform users that a method is deprecated while still providing backward compatibility.

Here's how you can apply the@deprecateddecorator to a method:

class User {
  @deprecated
  address() {
    // Method implementation
  }
}

When you call theaddressmethod, you'll see the deprecation warning message in the console.

Thanks for reading.

You are not logged in.