Demystifying Decorators in TypeScript: Empowering Your Code with Elegance

Typescript function decorator is a feature that enables the application of higher-order functions to class constructors and their members. See real examples below.

Tutorial on TypeScript Decorators with Real Examples

In TypeScript, decorators are powerful tools that let you modify or extend the behavior of classes, methods, properties, or parameters. They work with classes to add functionality or annotations without changing the original source code directly.

If you’re familiar with Python, you might recognize some similarities, but TypeScript decorators have their own unique approach to improving code organization and reusability. This blog article will discuss decorators, their syntax, and real-world examples of how they might improve TypeScript projects.

>> Read more: Mastering Utility Types in TypeScript For Better Code with Examples

What are Decorators in TypeScript?

Decorators are a TypeScript feature that enables the application of higher-order functions to class constructors and their members. These functions, known as decorators, are prefixed with the '@' symbol and are applied using special metadata to classes and class members during the compilation process.

Syntax of TypeScript Decorators

Defining a decorator in TypeScript involves creating a function that takes either three or two parameters:

Three Parameters Syntax

typescript
function decoratorFunction(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    // Example implementation
    console.log(`Decorating ${propertyKey} of class ${target.constructor.name}`);
}

class MyClass {
    @decoratorFunction
    myMethod() {
        // Method implementation
    }
}
  • target: This parameter refers to the constructor function of the class if the decorator is applied to a class, or the prototype of the class if applied to a class member (method, property, etc.). It represents the object that the decorator is applied to.
  • propertyKey: This parameter is a string that represents the name of the class member the decorator is applied to. It can be the name of a method, property, or any other class member.
  • descriptor: This parameter is of type PropertyDescriptor and contains information about the class member being decorated. It includes properties like value, writable, enumerable, and configurable. You can use this object to modify or inspect the behavior of the decorated member.

In this example, the decoratorFunction is applied to the myMethod of the MyClass. When myMethod is called, it will print a message like "Decorating myMethod of class MyClass" to the console.

Two Parameters Syntax (when applied to a class):

typescript
function decoratorFunction(target: any) {
    // Example implementation
    console.log(`Decorating class ${target.name}`);
}

@decoratorFunction
class MyClass {
    // Class definition
}
  • target: In this case, when the decorator is applied to a class, the target parameter represents the constructor function of the class. It's the same as the target parameter in the three-parameter syntax but without the other two parameters (propertyKey and descriptor).

These parameters allow decorators to interact with and modify class constructors and their members in various ways. You can use the target to access the class itself and its prototype, the propertyKey to identify the specific member being decorated, and the descriptor to inspect or modify the behavior of the member.

In this example, the decoratorFunction is applied directly to the MyClass. When the class is defined, it will print a message like "Decorating class MyClass" to the console.

In TypeScript, decorators are great tools for metaprogramming; knowing these parameters will help you to create efficient decorators that may elegantly change and improve your code.

TypeScript Decorators Usage

Decorators can be used in classes, methods, properties, and parameters in TypeScript:

typescript
// Decorator for class
function classDecorator(target: Function) {
    console.log(`Class Decorator: ${target.name}`);
}

// Decorator for method
function methodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log(`Method Decorator: ${propertyKey} in class ${target.constructor.name}`);
}

// Decorator for property
function propertyDecorator(target: any, propertyKey: string) {
    console.log(`Property Decorator: ${propertyKey} in class ${target.constructor.name}`);
}

// Decorator for parameter
function parameterDecorator(target: any, propertyKey: string, parameterIndex: number) {
    console.log(`Parameter Decorator: Parameter at index ${parameterIndex} of ${propertyKey} in class ${target.constructor.name}`);
}

@classDecorator
class ExampleClass {
    @propertyDecorator
    classProperty: string;

    constructor(@parameterDecorator private parameter: string) {}

    @methodDecorator
    exampleMethod() {}
}

const instance = new ExampleClass("Hello, World");
instance.exampleMethod();

In this example:

  • classDecorator is applied to the class itself (ExampleClass) and logs information about the class.
  • propertyDecorator is applied to the classProperty and logs information about the property within the class.
  • parameterDecorator is applied to the constructor parameter parameter and logs information about the parameter.
  • methodDecorator is applied to the exampleMethod and logs information about the method within the class.

When you create an instance of ExampleClass, you'll see the decorators in action, logging information about the class, properties, parameters, and methods. Here's the expected output:

typescript
Class Decorator: ExampleClass
Property Decorator: classProperty in class ExampleClass
Parameter Decorator: Parameter at index 0 of parameter in class ExampleClass
Method Decorator: exampleMethod in class ExampleClass

Real-world Examples

Logging Decorator

One practical use case of decorators is logging method calls for debugging and monitoring purposes. Let's implement a simple logging decorator:

typescript
function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
        console.log(`Calling method: ${propertyKey} with arguments: ${JSON.stringify(args)}`);
        const result = originalMethod.apply(this, args);
        console.log(`Method ${propertyKey} returned: ${JSON.stringify(result)}`);
        return result;
    };
}

class Calculator {
    @logMethod
    add(a: number, b: number): number {
        return a + b;
    }
}

const calc = new Calculator();
calc.add(5, 3); // Output will log method calls and return values.

The output of running calc.add(5, 3); will be:

javascript
Calling method: add with arguments: [5,3]
Method add returned: 8

This output demonstrates that the logMethod decorator logs information about the method call and its return value when the add method is invoked.

Authorization Decorator

Imagine you want to restrict access to certain methods based on user roles. Decorators can help enforce authorization rules in an elegant manner:

typescript
function authorize(allowedRoles: string[]) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;

        descriptor.value = function (...args: any[]) {
            // Check if user has allowedRoles
            const userRoles: string[] = ['admin', 'moderator']; // Simulating user roles
            const isAuthorized = allowedRoles.some(role => userRoles.includes(role));

            if (!isAuthorized) {
                throw new Error('Unauthorized! Insufficient privileges.');
            }

            return originalMethod.apply(this, args);
        };
    };
}

class AdminPanel {
    @authorize(['admin'])
    deleteUser(userId: string) {
        // Delete user logic here...
        console.log(`User with ID ${userId} deleted.`);
    }
}

const adminPanel = new AdminPanel();
adminPanel.deleteUser('123'); // Authorized

>> You may be interested in: The Impacts Of No-Code AI On App Development

Decorator Metadata in TypeScript 5.2

Decorator metadata is an interesting new tool introduced in TypeScript 5.2. This function lets decorators build and consume metadata on any class they're used on or within. Decorator metadata lets you quickly attach extra details to classes, properties, or methods.

Here's a brief example of how decorator metadata works:

typescript
// Define an interface for the context object.
interface Context {
    name: string;           // The name of the class member.
    metadata: Record<PropertyKey, unknown>; // A metadata object to store additional information.
}

// Define a decorator function called setMetadata.
// This decorator sets a flag in the metadata for a class member.
function setMetadata(_target: any, context: Context) {
    // Set the metadata flag to true for the given name.
    context.metadata[context.name] = true;
}

// Define a class called SomeClass.
class SomeClass {
    // Apply the setMetadata decorator to the 'foo' property.
    @setMetadata
    foo = 123;

    // Apply the setMetadata decorator to the 'bar' accessor (getter).
    @setMetadata
    accessor bar = "hello!";

    // Apply the setMetadata decorator to the 'baz' method.
    @setMetadata
    baz() { }
}

// Access the metadata stored in SomeClass using Symbol.metadata.
const ourMetadata = SomeClass[Symbol.metadata];

// Output the metadata as a JSON string for inspection.
console.log(JSON.stringify(ourMetadata));
// The output will show the metadata flags for each decorated member.
// { "bar": true, "baz": true, "foo": true }

In this example, the setMetadata decorator is used to attach metadata to class members. The metadata is stored in a dictionary-like object, and it can be accessed via the Symbol.metadata property on the class.

Sometimes, you may want to specify which properties or fields of an object should be included in the serialized JSON output. That's when Metadata Decorators come into play.

In TypeScript 5.2, you can achieve this using decorator metadata. Let's consider a practical example where we want to serialize instances of a Person class to JSON but only include specific properties marked with a @serialize decorator.

typescript
// Import the necessary decorator and utility functions
import { serialize, jsonify } from "./serializer";

// Define a class where we want to apply serialization metadata
class Person {
    firstName: string;
    lastName: string;

    @serialize
    age: number;

    @serialize
    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }

    constructor(firstName: string, lastName: string, age: number) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    // Define a custom method to generate JSON representation
    toJSON() {
        return jsonify(this);
    }
}

// Create an instance of the Person class
const person = new Person("John", "Doe", 30);

// Serialize the person object to JSON using the toJSON method
const serializedPerson = person.toJSON();

console.log(serializedPerson);
// Output: { "age": 30, "fullName": "John Doe" }

This is how the module ./serialize.ts might be defined:

typescript
const serializables = Symbol();
type Context =
    | ClassAccessorDecoratorContext
    | ClassGetterDecoratorContext
    | ClassFieldDecoratorContext
    ;
export function serialize(_target: any, context: Context): void {
    if (context.static || context.private) {
        throw new Error("Can only serialize public instance members.")
    }
    if (typeof context.name === "symbol") {
        throw new Error("Cannot serialize symbol-named properties.");
    }
    const propNames =
        (context.metadata[serializables] as string[] | undefined) ??= [];
    propNames.push(context.name);
}
export function jsonify(instance: object): string {
    const metadata = instance.constructor[Symbol.metadata];
    const propNames = metadata?.[serializables] as string[] | undefined;
    if (!propNames) {
        throw new Error("No members marked with @serialize.");
    }
    const pairStrings = propNames.map(key => {
        const strKey = JSON.stringify(key);
        const strValue = JSON.stringify((instance as any)[key]);
        return `${strKey}: ${strValue}`;
    });
    return `{ ${pairStrings.join(", ")} }`;
}

In this example, the Person class represents an individual with properties like firstName, lastName, and age. We want to serialize instances of this class to JSON but only include properties marked with the @serialize decorator.

The @serialize decorator is applied to both the age property and the fullName getter. This decorator adds metadata to these class members, indicating that they should be included in the serialized output.

The toJSON method is defined in the Person class to generate the JSON representation of the object. It calls the jsonify function, which utilizes the decorator metadata to determine which properties should be included in the JSON output.

The result is a JSON object that includes only the age property and the fullName property, as specified by the @serialize decorator. This level of control over serialization allows you to tailor the output to your specific requirements, improving code organization and maintainability.

Decorator metadata creates opportunities for several use cases, including serialization, debugging, or decorator-based dependency injection. It lets you improve the usability and organize your code creatively.

Decorator metadata helps you to provide your classes and their members context and behavior, hence improving their usability and functionality in practical settings. If you are interested in metadata, please read more about it in the original post at this link: TypeScript 5.2 Release Notes - Decorator Metadata.

>> You may consider:

Conclusion

TypeScript decorators are a powerful feature that allows developers to enhance their code with ease. Decorators can improve code readability, reusability, and maintainability for logging, authentication, and other functionalities.

Using decorators wisely will help you to produce more modular, clearer code and benefit from meta-programming. Knowing this will help you to start a path to include decorators into your TypeScript projects, therefore maintaining efficient and tidy codes. Happy coding!

>>> Follow and Contact Relia Software for more information!

  • coding
  • development