TypeScript input validation by Method Decorators

JavaScript/TypeScript

While I read a book on API Design Patterns, I found decorators were used in the examples.

It looks like this.

@get("resourceName")
function readData(){
    // something
}

According to the official site, Decorators are an experimental feature.

There might be specification changes in the future but it is useful if we can understand and use them. It is the first time for me to use decorators, so I want to implement some examples.

The following 4 decorators can be used but only Method and Parameter decorators will be used in this article.

  • Method Decorator
  • Accessor Decorator
  • Property Decorator
  • Parameter Decorator

Let’s learn together with me.

Sponsored links

Prerequisite to use decorators

Decorators are an experimental feature. Therefore, it’s not enabled by default. To use it, we need the following.

tsc --target ES5 --experimentalDecorators

or

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true
  }
}

Try Method Decorator example from official site

Firstly, we should know how Decorators work. Let’s take an example from the official site and add some statements.

// decorators.ts
export function first(): MethodDecorator {
    console.log("first(): factory evaluated");
    return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
        console.log("first(): called");
        console.log(`target: ${JSON.stringify(target, null, 2)}`);
        console.log(`name: ${target.constructor.name}`);
        console.log(`propertyKey: ${propertyKey.toString()}`);
        console.log(descriptor);
    };
}

export function second(): MethodDecorator {
    console.log("second(): factory evaluated");
    return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
        console.log("second(): called");
    };
}

// app.ts
class ExampleClass {
    @first()
    @second()
    public method() {
        console.log("method is called.");
        return 1;
    }
}

It returns the decorator function. If we run the code above, we can get the following output even though the methods are not called.

$ tsc && node dist/decorator/app.js
first(): factory evaluated
second(): factory evaluated
second(): called
first(): called
target: {}
name: ExampleClass
propertyKey: method
{
  value: [Function: method],
  writable: true,
  enumerable: false,
  configurable: true
}

The decorator factory function is called first. Then, the decorator itself is called. The logic in the decorator itself is also executed. If we want to execute something when the method is called, we need to add the logic to descriptor.value. I will show it to you later.

The meaning of the 3 arguments is as follows.

1. Either the constructor function of the class for a static member, or the prototype of the class for an instance member.
2. The name of the member.
3. The Property Descriptor for the member.

https://www.typescriptlang.org/docs/handbook/decorators.html#method-decorators

Hmm… I’m still not familiar with the prototype and Property Descriptor. If you are also not sure, check the following pages.

Let’s add the following method call.

console.log("-----execute------")
const instance = new ExampleClass();
console.log("method: " + instance.method());

The output is normal because the decorators don’t do anything.

-----execute------
method is called.
method: 1

Adding a execution time logic

When we find a performance problem in our application, we must check which is the slowest part. In this case, we need to add the measurement logic to all suspicious functions. It looks like this below.

function suspiciousMethod(){
    console.time("method1");

    // original logic here

    console.timeEnd("method1");
}

However, it is really cumbersome work that we don’t want to do.

If we use a decorator, it is much easier to do it. What we need to do is only add the decorator like the following.

@log
function suspiciousMethod(){
    // original logic here
}

Let’s check the decorator function first.

export function log(target: any, propertyKey: string, descriptor: PropertyDescriptor): void {
    const originalMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
        const key = `${target.constructor.name}${propertyKey}`;
        console.log(`start: ${key}`);
        console.time(key);
        const result = originalMethod.apply(this, args);
        if (isPromise(result)) {
            return result.then((x: unknown) => {
                console.timeEnd(key);
                return x;
            });
        }
        console.timeEnd(key);
        return result;
    }
}
function isPromise(object: unknown): boolean {
    return Object.prototype.hasOwnProperty.call(object, "then") &&
        Object.prototype.hasOwnProperty.call(object, "catch");
}

The declaration matches MethodDecorator. descriptor.value is the variable that is called when the original function is called.

It means that the original logic is defined in it. To overwrite the behavior, we need to store it with the following code.

const originalMethod = descriptor.value;

Then, we can implement whatever we want.

descriptor.value = async function (...args: any[]) {
    // Something that we want to do before the original call

    // Call the original method
    const result = originalMethod.apply(this, args);

    // Something that we want to do after the original call
    return result;
}

Then, the important point is to check whether it’s Promise or not. We should NOT implement it in the following way.

// DON'T USE. BAD CODE
descriptor.value = function (...args: any[]) {
    const key = `${target.constructor.name}${propertyKey}`;
    console.log(`start: ${key}`);
    console.time(key);
    // use await here
    const result = await originalMethod.apply(this, args);
    console.timeEnd(key);
    return result;
}

If the method is a synchronous method, it becomes an asynchronous method here. It leads to an unexpected result.

Let’s try to use it.

class ExampleClass {
    @log
    public asyncMethod() {
        console.log("asyncMethod is called");
        return new Promise((resolve) => global.setTimeout(() => resolve(50), 2000));
    }

    @log
    public syncMethod() {
        console.log("syncMethod is called");
        return 11;
    }
}

console.log("-----execute------")
const instance = new ExampleClass();
console.log(instance.syncMethod());
instance.asyncMethod().then((result) => console.log(`resolved: ${result}`));

The result is the following.

-----execute------
start: ExampleClasssyncMethod
syncMethod is called
ExampleClasssyncMethod: 0.908ms
11
start: ExampleClassasyncMethod
asyncMethod is called
ExampleClassasyncMethod: 3.38ms
resolved: 50

We can easily check the execution time for each function in this way.

Input validation by a Decorator

We can add input validation to the desired method by decorators.

Check if the input string is not empty

The following example is to check if the input value is not an empty string.

class ExampleClass {
    @validateString
    public testEmptyString(
        @notEmpty value1: string,
        value2: string,
        @notEmpty value3: string,
    ) { }
}

validateString is a method decorator but it’s not possible to validate the parameter on its alone because it doesn’t have parameter info.

notEmpty is used as a parameter decorator to provide the info.

Implementation of a Parameter Decorator

Let’s check the implementation of the parameter info.

// decorators.ts
export const MyDecorators = {
    NotEmpty: "NOT_EMPTY",
} as const;

export function notEmpty(target: any, propertyKey: string, index: number): void {
    const list = Reflect.getOwnMetadata(MyDecorators.NotEmpty, target, propertyKey);
    console.log(target);
    console.log(`propertyKey: ${propertyKey}`);
    console.log(`index: ${index}`);
    if (list) {
        list.push(index);
    } else {
        Reflect.defineMetadata(MyDecorators.NotEmpty, [index], target, propertyKey);
    }
}

The first and the second parameters are the same as the one for a method decorator. The third one is the index of the parameter. If a method requires multiple arguments, a method decorator needs to know which parameter it is checking now.

Reflect.defineMetadata stores the parameter metadata with the key and Reflect.getOwnMetadata reads it.

The decorators are called, I guess when the module is loaded. Index 1 is not shown in the result since I added the decorator to the first and third parameters.

{}
propertyKey: testEmptyString
index: 2
{}
propertyKey: testEmptyString
index: 0

Implementation of the validation logic in a method decorator

The next step for the validation is to implement a method decorator. This is the complete code.

export function validateString(target: any, propertyKey: string, descriptor: PropertyDescriptor): void {
    const list: number[] = Reflect.getOwnMetadata(MyDecorators.NotEmpty, target, propertyKey);
    console.log("-------validateString");
    console.log(list);

    if (!list) {
        return;
    }

    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any) {
        // list contains indexes of the args which has a parameter decorator
        const invalid = list.filter((index) => {
            const currentArg = args[index];
            console.log(`current arg value: '${currentArg}'`)
            return currentArg.trim() === "";
        });

        if (invalid.length > 0) {
            throw new Error(`Empty string detected!`);
        }
        // Execute original method
        Reflect.apply(originalMethod, this, args);
    }
}

It gets data parameters’ data on the first line by Reflect.getOwnMetadata with the same key as the parameter decorator shown above. The output of the first console.log is the following.

-------validateString
[ 2, 0 ]

All parameters are passed from args. Therefore, we can get the target parameter by args[index]. Once we get the target parameter’s value, we can add the check logic there.

const invalid = list.filter((index) => {
        const currentArg = args[index];
        console.log(`current arg value: '${currentArg}'`)
        return currentArg.trim() === "";
    }).map((index) => args[index]);

invalid variable stores the invalid parameters’ indexes.

const execute = (action: () => void) => {
    try {
        console.log(action());
    } catch (e) {
        console.error(`=== Error ===> ${(e as Error).message}`);
        console.log();
    }
}
console.log("-----execute------")
const instance = new ExampleClass();
execute(() => instance.testEmptyString("", "", ""));
// current arg value: ''
// current arg value: ''
// === Error ===> Empty string detected!

execute(() => instance.testEmptyString("111", "", ""));
// current arg value: ''
// current arg value: '111'
// === Error ===> Empty string detected!

execute(() => instance.testEmptyString("999", "", "888"));
// current arg value: '888'
// current arg value: '999'
// undefined

If it contains an empty string in the first or the third parameter, it throws an error. It’s not necessary to write similar code in the same function to check the two parameters.

Check the parameters if they are one of possible values

When we need to validate the parameters but the possible values depend on the function. In this case, we want to specify the values in the decorator. How can we do this?

What we want is the following. The possible values are passed to the parameters of the decorator.

class ExampleClass {
    @validate
    public testRestriction(
        @restrictTo([99, 45, 12]) value1: number,
        @restrictTo(["hello", "foo"]) value2: string,
        value3: string,
    ) { }
}

OK, we need to create the possible value list dynamically. We can use a decorator factory in this case. It returns a decorator.

// decorators.ts
export interface Restriction<T> {
    index: number;
    validList: T[];
}

export function restrictTo<T>(validList: T[]): ParameterDecorator {
    return (target: any, propertyKey: string | symbol, index: number) => {
        const list = Reflect.getOwnMetadata(MyDecorators.Restriction, target, propertyKey);
        console.log("------------restrict")
        console.log(MyDecorators.Restriction)
        console.log(target);
        console.log(propertyKey);
        console.log(list);
        console.log(`index: ${index}`);
        const obj = {
            index,
            validList,
        };
        if (list) {
            list.push(obj);
        } else {
            Reflect.defineMetadata(MyDecorators.Restriction, [obj], target, propertyKey);
        }
    };
}

In the previous example, we put only the index. We need to pass the possible value list as well. Therefore, this function requires validList and passes it to an object. Then, a method decorator can use the valid list.

export function validate(target: any, propertyKey: string, descriptor: PropertyDescriptor): void {
    const list: Restriction<string | number>[] = Reflect.getOwnMetadata(MyDecorators.Restriction, target, propertyKey);
    if (!list) {
        return;
    }
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any) {
        const invalid = list
            .filter((obj) => {
                const currentArg = args[obj.index];
                return !obj.validList.includes(currentArg);
            }).map((x) => args[x.index]);

        if (invalid.length > 0) {
            throw new Error(`Invalid values detected! [${invalid.join(", ")}]`);
        }

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

The other things are the same. Only the difference is to use the validList in it.

The test code is the following.

console.log("-----execute------")
const instance = new ExampleClass();

execute(() => instance.testRestriction(99, "hello", "hey"));
// undefined
execute(() => instance.testRestriction(45, "foo", "hey"));
// undefined
execute(() => instance.testRestriction(45, "not-allowed", "hey"));
// === Error ===> Invalid values detected! [not-allowed]
execute(() => instance.testRestriction(46, "hello", "hey"));
// === Error ===> Invalid values detected! [46]
execute(() => instance.testRestriction(46, "not-allowed", "hey"));
// === Error ===> Invalid values detected! [not-allowed, 46]

It works nicely.

Convert the result by decorator

We’ve understood the usage of the decorator. We can change the output by a decorator. The following example adds the framework to the string result.

// decorators.ts
export function starFrame(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function (...args: any) {
        const result = Reflect.apply(originalMethod, this, args);
        if (typeof result === "string") {
            const top = "*".repeat(result.length + 4);
            const middle = `* ${result} *`;
            const bottom = "*".repeat(result.length + 4);
            return `${top}\n${middle}\n${bottom}`;

        }
        return result;
    };
}

// app.ts
class ExampleClass {
    @starFrame
    public changeReturnedValue(
    ) {
        return "Hello. This is decorator implementation test.";
    }
}

console.log("-----execute------")
const instance = new ExampleClass();

console.log(instance.changeReturnedValue());
// *************************************************
// * Hello. This is decorator implementation test. *
// *************************************************

This is just a decoration but we can decode/encode the result if necessary.

Ends

It is still an experimental phase but if we use it, we can more easily implement it. I’ve never used it in my project but if I have a chance, I want to try to use it.

Comments

Copied title and URL