iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article

Using Decorators to Store Previous Property Values Before Class Method Execution in TypeScript

に公開

Introduction

Looking at the title, you might not be able to think of many use cases for this.
So, first, let me talk about the case that motivated me to write this article.
In the application I was implementing, there was a notification feature.
This notification is sent to the relevant people when an administrator is added or deleted.
Here is a conceptual image.
event.drawio.png
Initially, I first retrieved the existing administrator information, sent a notification to them, and then sent a notification to the target person again once the new administrator was set.
However, I reviewed the logic where the notification process runs and modified it so that notifications are sent in the interceptor part, which is executed upon responses like the following.
event-interceptor.drawio.png
There are two main reasons why I processed it in an interceptor like this.
First, the number of endpoints related to administrator permissions might increase in the future, and I wanted to avoid implementing the same logic every time a new endpoint is added.
By centralizing the notification process in the interceptor, notifications will be sent automatically even if more endpoints related to administrator permissions are added.
I'd like to write an article about this implementation someday, but since it's different from today's main topic, I'll omit the details.
Second, I wanted to execute the notification process via events to decouple it from the main add/delete requests.
By decoupling, I wanted to shorten the overall request completion time and ensure that even if an error occurs in the notification logic, the main process itself doesn't stop.
Due to the above background, I changed the location where notifications are executed, but a problem occurred.
To examine this, let's first look at the original flow for retrieving the target people for notifications.
The people to whom notifications were sent were extracted from the administrators property of the following class.

class Group {
  administrators: number[];
}

The original processing flow was as follows:
Create a Group class that holds the current state based on the ID included in the request.
→ Retrieve the administrators property and notify matching users
→ Update the administrators property of the Group class based on the request content
→ Retrieve the administrators property again and notify matching users.
→ Persist the Group class
The notification process was executed before and after the state was modified.
However, because I centralized the notification process in an interceptor that runs upon response, I can only retrieve the users after the change.
Even if I want to notify existing users, I can no longer do so because the information is gone.
Therefore, I modified the Group class and added the following property.

class Group {
  administrators: number[];
  prevAdministrators: number[];
}

I set the value at the beginning of the method as follows to retrieve the users before the change.

class Group {
  administrators: number[];
  prevAdministrators: number[];
  deleteInsert() {
    this.prevAdministrators = this.administrators;
    /** Process to update the administrators property */
  }
}

With this, I can now retrieve the existing administrator information even in the interceptor.
All's well that ends well.
Or so I'd like to say, but as I mentioned earlier, endpoints for administrator information might be edited quite a bit in the future.
Along with that, methods in the Group class will increase or decrease, and each time, I'll need to write this.prevAdministrators = this.administrators; at the beginning of the method.
Eventually, someone will definitely forget it, and cases will arise where notification targets cannot be properly retrieved.
This article is about the method to prevent that.
I will create a custom decorator so that just by adding it above the class like this, the process to set the value of one property to another property will automatically run before the method executes.

@Hoge("hoge")
class Group {}

By doing this, appropriate administrator information can be retrieved regardless of the implementer's diligence.
That is the background for writing this article.
Now, let's look at the implementation.

Explanation of Implementation

Overall Code

First, I'll present the overall code.

function StorePrevValue<U extends { prototype: object }>(
  values: {
    prpropertyNameForSet: ExtractProperties<U["prototype"]>;
    prpropertyNameForGet: ExtractProperties<U["prototype"]>;
  }[]
) {
  return function <T extends { new (...args: any[]): {} }>(constructor: T) {
    /** Get all method names existing in the class.
     *  However, exclude the constructor as we don't want property overwriting logic to run for it.
     */
    const methods = Object.getOwnPropertyNames(constructor.prototype).filter(
      (method) => method !== "constructor"
    );
    methods.forEach((methodName) => {
      const originalMethod = constructor.prototype[methodName];
      if (typeof originalMethod === "function") {
        /** Overwrite the function */
        constructor.prototype[methodName] = function (
          this: any,
          ...args: any[]
        ) {
          /** Store the value before the method executes into the specified property */
          values.forEach((val) => {
            this[val.prpropertyNameForSet] = this[val.prpropertyNameForGet];
          });
          /** Call the original method */
          return originalMethod.apply(this, args);
        };
      }
    });
    return constructor;
  };
}
type ExtractProperties<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];
@StorePrevValue<typeof Group>([
  {
    prpropertyNameForSet: "prevAdministrators",
    prpropertyNameForGet: "administrators",
  },
])
class Group {
  administrators: number[];
  readonly prevAdministrators: number[] = [];
  constructor(administrators: number[]) {
    this.administrators = administrators;
  }
  setAdministrators(administrators: number[]) {
    this.administrators = administrators;
  }
}
const group = new Group([1, 2]);
group.setAdministrators([4, 5, 6]);
console.log(group.prevAdministrators, "prevAdministrators");
console.log(group.administrators, "administrators");
group.setAdministrators([7, 8, 9]);
console.log(group.prevAdministrators, "prevAdministrators_2");
console.log(group.administrators, "administrators_2");

I've also attached a link to the playground below.
Playground

Now, let's look at each point of interest in turn.

Extracting Properties from a Class: ExtractProperties

This is the following part of the code:

type ExtractProperties<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

Let's check each process.
First, [K in keyof T] extracts the properties of the generic type T using keyof.
And since we are using in, it is required that all properties included in T be defined within the object.

Let's look at an example. We have the following type definition:

type ObjectTest = {
  id: number;
  id2: string;
  id3: string;
};
type NewObject = {
  [K in keyof ObjectTest]: any;
};

Now, let's define a variable of type NewObject.

const testObject: NewObject = {};

If we look at the auto-completion, it looks like this:
2024-08-03_17h12_56.png

The properties of the ObjectTest type appear. A type error will occur for the testObject variable unless id, id2, and id3 are defined.

However, since the values in the NewObject type are set to any, they don't need to match the values in the ObjectTest type.
By defining properties in the form [K in keyof T], you can enforce that all properties of T are defined.
Playground

Now that we've checked the property part, let's look at the value's type definition: T[K] extends Function ? never : K.
This makes the type never if the value of property K in T is a function, and otherwise uses K as the type definition.
Roughly speaking, it represents a ternary operator for type definitions.

Let's verify this with an example. Suppose we have the following class:

class SimpleClass {
  name: string = "";
  age: number = 1;
  greet() {
    console.log("Hello");
  }
}

Using this class, let's apply the part of the previous type definition excluding [keyof T].

/** An error occurs because there is no value for 'never' */
const test: {
  [K in keyof SimpleClass]: SimpleClass[K] extends Function ? never : K;
} = {
  age: "age",
  name: "name",
  greet: never,
};

Since age and name are not functions, only the strings of their respective property names can be defined. Please note that since the property name K itself becomes the type, you cannot set an arbitrary string.
Furthermore, since the function is of type never, you cannot input the property name. In fact, a property like greet above cannot even be set because its type is never. Therefore, the test variable cannot be defined.
Please note that I only described this for illustrative purposes.

As something that actually works, we define the following intermediate type:

type SampleClassTypes = {
  [K in keyof SimpleClass]: SimpleClass[K] extends Function ? never : K;
};

As can be seen from the behavior of the variable we just looked at, this is synonymous with the following type:

type SampleClassTypes = {
  name: "name";
  age: "age";
  greet: never;
};

Playground

Now that we've looked at the type definition for the object part, let's finally check the behavior of the part involving [keyof T] in:

{
  [K in keyof T]: T[K] extends Function ? never : K
}[keyof T]

Please note that since I'm describing this based on my own understanding, I cannot guarantee that it's an exact description of the actual internal behavior.

keyof T creates a union type of the object's properties.
For example, suppose we have the following type definition:

type TestObject = {
  id: string;
  name: string;
};

When we apply keyof to this TestObject type, the following relationship holds:

type TestObjectKeys = keyof TestObject = 'id' | 'name'

You can only input the names of the properties.
If you try defining a variable using the TestObjectKeys type, string completion will appear as follows:
2024-08-03_21h39_39.png

Only the strings id or name can be set to the variable key; anything else will result in an error.
In this way, applying keyof to an object creates a union type of its properties.
Playground

With this in mind, let's see how {}[keyof T] behaves.
keyof T creates a union type, which is then enclosed in square brackets.
In this case, these square brackets represent property access to the object rather than an array.
It's similar to the following conceptual image:

const testObj = {
  id: 1,
};
const id = testObj["id"];

From this, {}[keyof T] becomes a union type of the types corresponding to each property of the object.
Let's verify this using the TestObject we created earlier.
We'll define an ExtractProperties type that is somewhat close to the original definition as follows:

type TestObject = {
  id: number;
  name: string;
};
type ExtractProperties = {
  [K in keyof TestObject]: any;
}[keyof TestObject];

The ExtractProperties type becomes synonymous with the following type:

type ExtractProperties =
  | { id: any; name: any }["id"]
  | { id: any; name: any }["name"];

Just like with regular objects, when accessing a type with a specified property, it returns the type set for that property's value.
In other words, the previous ExtractProperties is interpreted as follows:

type ExtractProperties = any | any;

Since both are any types, for this example, the type becomes type ExtractProperties = any.
Next, let's move closer to the original type definition.
For the sake of explanation, I'll create TestObject2 below:

type TestObject2 = {
  id: number;
  name: string;
  func: () => void;
};

Based on this type, we set ExtractProperties2.

type ExtractProperties2 = {
  [K in keyof TestObject2]: TestObject2[K] extends Function ? never : K;
}[keyof TestObject2];

From the previous explanation, the ExtractProperties2 type is synonymous with the following:

type ExtractProperties2 = "id" | "name" | never;

However, in a union type, defining never is ignored.
In other words, the ExtractProperties2 type is interpreted as follows:

type ExtractProperties2 = "id" | "name";

Playground

Finally, the whole picture is clear.
From the above, we now understand why the following code works to extract only property names from any class while skipping methods:

type ExtractProperties<T> = {
  [K in keyof T]: T[K] extends Function ? never : K;
}[keyof T];

However, this is just the prelude.
Next, we'll look at the main part: the definition of the decorator.

Decorator for Setting Values to Specified Properties

This is the following part of the code:

function StorePrevValue<U extends { prototype: object }>(
  values: {
    prpropertyNameForSet: ExtractProperties<U["prototype"]>;
    prpropertyNameForGet: ExtractProperties<U["prototype"]>;
  }[]
) {
  return function <T extends { new (...args: any[]): {} }>(constructor: T) {
    /** Get all method names existing in the class.
     *  However, exclude the constructor as we don't want property overwriting logic to run for it.
     */
    const methods = Object.getOwnPropertyNames(constructor.prototype).filter(
      (method) => method !== "constructor"
    );
    methods.forEach((methodName) => {
      const originalMethod = constructor.prototype[methodName];
      if (typeof originalMethod === "function") {
        /** Overwrite the function */
        constructor.prototype[methodName] = function (
          this: any,
          ...args: any[]
        ) {
          /** Store the value before the method executes into the specified property */
          values.forEach((val) => {
            this[val.prpropertyNameForSet] = this[val.prpropertyNameForGet];
          });
          /** Call the original method */
          return originalMethod.apply(this, args);
        };
      }
    });
    return constructor;
  };
}

Before examining how this part works, let's first look at what decorators are in TypeScript.

What are Decorators?

According to the TypeScript announcement, decorators are described as follows:

Decorators are an upcoming ECMAScript feature that allow us to customize classes and their members in a reusable way.

They are useful for customizing classes and their internal methods in a reusable manner.
In terms of code, if you are developing with NestJS, you likely often write words starting with @ above methods or classes.
That is how you call a decorator.
We understand that they are helpful for customization and function as decorators when they start with @.
However, it’s not immediately clear why this customization works.
So, let's look at how decorators are processed.
First, as a premise, a decorator is a function.
Looking at the TypeScript announcement, the underlying process of a decorator is described as follows:

function loggedMethod(originalMethod: any, _context: any) {
  function replacementMethod(this: any, ...args: any[]) {
    console.log("LOG: Entering method.");
    const result = originalMethod.call(this, ...args);
    console.log("LOG: Exiting method.");
    return result;
  }
  return replacementMethod;
}

By simply setting it as follows, the loggedMethod function will be executed when the greet method is called.

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
  @loggedMethod
  greet() {
    console.log(`Hello, my name is ${this.name}.`);
  }
}

If you check it in the Playground, you can see that loggedMethod is being called.
However, you might notice a slightly mysterious behavior when checking it in the Playground.
This is because the log is output as follows:

[LOG]: "LOG: Entering method."
[LOG]: "Hello, my name is Ron."
[LOG]: "LOG: Exiting method."

Since the loggedMethod function is called before greet is executed, you might intuitively expect "Hello, my name is Ron." to be output last.
However, "Hello, my name is Ron." actually appears in the middle.
This is because the loggedMethod function is overwriting the greet method.
A decorator function is always provided with the method to be executed and information about the method type as arguments.
The method to be executed is the first argument, and the method information is the second argument.
With this in mind, let's look at the code for the decorator function again.

function loggedMethod(originalMethod: any, _context: any) {
  function replacementMethod(this: any, ...args: any[]) {
    console.log("LOG: Entering method.");
    const result = originalMethod.call(this, ...args);
    console.log("LOG: Exiting method.");
    return result;
  }
  return replacementMethod;
}

It defines a new function called replacementMethod and returns it.
Inside the replacementMethod function, it adds console.log and calls originalMethod.
Since originalMethod receives the method, in this case, it corresponds to the greet method.
The fact that it is being called shows that the original greet method will be executed.*
However, when called as person.greet(), the process of the replacementMethod function, which is the return value of the loggedMethod function, is called.
Therefore, we can say that a decorator function is a function that overwrites the original method while making use of it.
*Since I don't fully understand methods like call that belong to prototype, I'm not explaining the first argument this here. For more details, please refer to documentation such as MDN.
That concludes the check on the behavior of decorators.
The reason they are customizable is that decorators can receive the original method and overwrite it.
Trying it out, I feel that decorators are indeed a mechanism that makes customization easy.
Note that while we focused on decorators for methods, if you set a decorator function on a class as follows, the constructor function will be passed as the first argument.

@Hoge
class Piyo {}

I might be mistaken since I don't perfectly understand constructor functions myself, but as long as you implement it with the awareness that information about the class where the decorator is set is coming in, there should be no problem.
Since the decorator function implemented this time is for classes, this is what will be set as the argument.
Now that we've checked decorators, let's finally explain the contents of our decorator function.

Examining What the StorePrevValue Decorator Function Does

First, let's repost the StorePrevValue function.

function StorePrevValue<U extends { prototype: object }>(
  values: {
    prpropertyNameForSet: ExtractProperties<U["prototype"]>;
    prpropertyNameForGet: ExtractProperties<U["prototype"]>;
  }[]
) {
  return function <T extends { new (...args: any[]): {} }>(constructor: T) {
    /** Get all method names in the class.
     *  However, we exclude the constructor because we don't want the property overwriting process to run for it.
     */
    const methods = Object.getOwnPropertyNames(constructor.prototype).filter(
      (method) => method !== "constructor"
    );
    methods.forEach((methodName) => {
      const originalMethod = constructor.prototype[methodName];
      if (typeof originalMethod === "function") {
        /** Overwrite the function */
        constructor.prototype[methodName] = function (
          this: any,
          ...args: any[]
        ) {
          /** Store the value before the method is executed into the specified property */
          values.forEach((val) => {
            this[val.prpropertyNameForSet] = this[val.prpropertyNameForGet];
          });
          /** Call the original method */
          return originalMethod.apply(this, args);
        };
      }
    });
    return constructor;
  };
}

Let's examine it step by step.

First, the definition of the StorePrevValue function:

StorePrevValue<U extends { prototype: object }>(values: { prpropertyNameForSet: ExtractProperties<U['prototype']>, prpropertyNameForGet: ExtractProperties<U['prototype']> }[])

It expects a class object to be passed to the generic type U.
To achieve this, we explicitly state that it must be an extension of an object that has prototype as a property.
This is because I wanted to specify the properties—the one that holds the existing value before executing the method and the one that provides the value to be held—in a way that provides auto-completion.

The reason auto-completion works is that the ExtractProperties type discussed earlier is specified as the type for the arguments.
By using the ExtractProperties type, we can extract only the properties of the class's prototype as a union type, preventing typos in the values.

Note that I mentioned earlier that a class decorator receives the constructor function as its first argument.
However, we are not expecting the constructor function as the first argument here.
Even so, this works correctly.
This is because when decorating a class, instead of using @StorePrevValue, you need to execute the function once, like @StorePrevValue([]).
By doing this, the following function defined internally is set as the class decorator:

return function <T extends { new (...args: any[]): {} }>(constructor: T) {
  /** Omitted */
  return constructor;
};

Therefore, while you don't necessarily have to set the constructor function as the first argument when defining the initial function, you must return a function that takes the constructor function as its first argument to use it as a class decorator.
In this implementation, it is not a problem because we return a function that accepts a constructor function.

Now, let's look at the internal logic.

    /** Get all method names existing in the class.
     *  However, exclude the constructor as we don't want the property overwriting logic to run for it.
     */
    const methods = Object.getOwnPropertyNames(constructor.prototype).filter(
      (method) => method !== "constructor"
    );
    methods.forEach((methodName) => {
      const originalMethod = constructor.prototype[methodName];
      if (typeof originalMethod === "function") {
        /** Overwrite the function */
        constructor.prototype[methodName] = function (
          this: any,
          ...args: any[]
        ) {
          /** Store the value before the method executes into the specified property */
          values.forEach((val) => {
            this[val.prpropertyNameForSet] = this[val.prpropertyNameForGet];
          });
          /** Call the original method */
          return originalMethod.apply(this, args);
        };
      }
    });
    return constructor;
  };
}

As stated in the comments within the code, what I wanted to achieve was to overwrite the function with constructor.prototype[methodName] = function (this: any, ...args: any[]) {}; and set the values to the specified properties using values.forEach((val) => { this[val.prpropertyNameForSet] = this[val.prpropertyNameForGet]; });.

After that, the method is executed. Now that the decorator function is defined, we finally apply it to the class as follows:

@StorePrevValue<typeof Group>([
  {
    prpropertyNameForSet: "prevAdministrators",
    prpropertyNameForGet: "administrators",
  },
])
class Group {
  administrators: number[];
  readonly prevAdministrators: number[] = [];
  /** Omitted */
}

To use it, first specify the type of the class you want to execute it on in the <> part of the StorePrevValue function using typeof. This allows the values set in the arguments to benefit from auto-completion, as shown in the image.
2024-08-04_17h22_27.png

Once you specify the property to hold the previous value and the property containing the value to be held, you can confirm that the value is set in the specified property before the method runs. Please check the behavior in the Playground.
Additionally, I recommend adding readonly to properties you want to preserve. By adding readonly, you can ensure that values aren't replaced within the class, preventing unexpected values.

Conclusion

This article explained how to save properties before their values change when executing methods within a class. It was quite a challenge as there were many things to understand relative to the scale of the goal. However, thanks to that effort, I was able to truly experience the behavior and effectiveness of decorators, which I had previously written somewhat intuitively. Overusing them can make code difficult to read, but I hope to apply them in situations where they are useful.
Thank you for reading this far.

Discussion