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

In the world of TypeScript, decorators are powerful tools that provide an elegant way to modify or extend the behavior of classes, methods, properties, or parameters. They are typically used in combination with classes and provide a way to annotate or extend the behavior of these elements without modifying their source code directly.

If you come from a Python background, you might find the concept familiar, but TypeScript decorators are distinct and offer a fresh perspective on code organization and reusability. In this blog post, we will dive into decorators, explore their syntax, and demonstrate real-world examples to illustrate how they can enhance your 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

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):

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.

Decorators are powerful tools for metaprogramming in TypeScript, and understanding these parameters is essential for creating effective decorators that can modify and enhance your code elegantly.

TypeScript Decorators Usage

Decorators can be used in classes, methods, properties, and parameters in 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:

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:

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:

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:

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

TypeScript 5.2 introduces an exciting new feature called decorator metadata. This feature allows decorators to create and consume metadata on any class they're used on or within. With decorator metadata, you can easily attach additional information to classes, properties, or methods.

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

// 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.

// 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:

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 opens up possibilities for various use cases, such as debugging, serialization, or performing dependency injection with decorators. It allows you to enhance your code's functionality and organization in creative ways, making your code more elegant and efficient.

Decorator metadata empowers you to add context and behavior to your classes and their members, enhancing their functionality and usability in real-world scenarios. 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 remarkable feature that empowers developers to enhance their code with minimal effort and maximum elegance. Whether you're implementing features like logging, authentication, or other cross-cutting concerns, decorators offer endless possibilities for improving code readability, reusability, and maintainability.

By using decorators judiciously, you can achieve cleaner, more modular code and gain the benefits of meta-programming. With this newfound knowledge, you can embark on a journey to incorporate decorators into your TypeScript projects, keeping your code organized and efficient. Happy coding!

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

  • coding
  • development