I want to write a sanitizer decorator which I can put on all user-input string fields. This simply replaces the standard .set(newValue)
with .set( sanitize(newValue) )
. However I have found the below code only works for one instance. A second instance of the same class ends up sharing the currentValue. After further reading this is actually expected, but I can't work out how to make it per-instance.
import "reflect-metadata";
export const Sanitize = () => {
return (target: any, propertyKey: string | symbol) => {
let currentValue: any = sanitiseString(options, `${target[propertyKey] || ''}`);
Reflect.deleteProperty(target, propertyKey);
Reflect.defineProperty(target, propertyKey, {
get: () => currentValue,
set: (newValue: string) => {
currentValue = sanitiseString(newValue);
},
});
}
}
Edit 1: Minimum reproducible example:
import "reflect-metadata";
const sanitiseString = (valToSanitise: string) => {
// do some stuff, return clean value
return valToSanitise;
}
const Sanitize = () => {
return (target: any, propertyKey: string | symbol) => {
let currentValue: any = sanitiseString(`${target[propertyKey] || ''}`);
Reflect.deleteProperty(target, propertyKey);
Reflect.defineProperty(target, propertyKey, {
get: () => currentValue,
set: (newValue: string) => {
currentValue = sanitiseString(newValue);
},
});
}
}
class UserInput {
constructor(propOne: string, propTwo: string, propThree: number) {
this.propOne = propOne;
this.propTwo = propTwo;
this.propThree = propThree;
}
@Sanitize() propOne: string
@Sanitize() propTwo: string
propThree: number
}
const inputOne = new UserInput('input 1, prop 1', 'input 1, prop 2', 1)
const inputTwo = new UserInput('input 2, prop 1', 'input 2, prop 2', 2)
console.log(inputOne)
console.log(inputTwo)
// expected output:
// [LOG]: UserInput: {
// "propOne": "input 1, prop 1",
// "propTwo": "input 1, prop 2",
// "propThree": 1
// }
// [LOG]: UserInput: {
// "propOne": "input 2, prop 1",
// "propTwo": "input 2, prop 2",
// "propThree": 2
// }
//
// actual output:
//
// [LOG]: UserInput: {
// "propThree": 1
// }
// [LOG]: UserInput: {
// "propThree": 2
// }
// When you remove @Sanitize() the fields appear in console.log. When you add @Sanitize() the fields disappear.
// Further, forcing console.log(inputOne.propOne) returns [LOG]: "input 2, prop 1"
// indicating that the property is being written for the class proto and not per instance
console.log(inputOne.propOne)
The main issue here is that Sanitize()
gets called once per decorated class property declaration, and so for any given class property there will be only one currentValue
. Meaning that two instances of the class will share the same currentValue
. If you want to store one value per decorated class property per class instance, then you need access to class instances, and you will have to store the value either in those instances (via a property key that isn't going to interfere with any other properties), or in some map whose key is those instances. In the following I will show how to store the value in the class instances, and to avoid worrying about property name collision I will use the symbol
output of the Symbol
function, which is guaranteed to be unique.
Also note that Sanitize()
is passed the class prototype as the target
argument, so any manipulation you perform on target
will affect the prototype and not any instance of the class. When you write target[propertyKey]
, you are looking up the property in the class prototype, and string
-valued properties will almost certainly not be set in the prototype. So this is probably not necessary or useful, and we should get rid of it.
So if you only have direct access to the class prototype, how do you do anything with class instances? Well, to do this, you should use the this
context of the get
method and set
method of the accessor property descriptor you pass to defineProperty()
. And that means get
and set
need to be methods or at least function
expressions, and not arrow function expressions which have no distinct this
context.
Okay, enough explanation, here's the code:
const Sanitize = () => {
return (target: any, propertyKey: string | symbol) => {
const privatePropKey = Symbol();
Reflect.defineProperty(target, propertyKey, {
get(this: any) {
return this[privatePropKey]
},
set(this: any, newValue: string) {
this[privatePropKey] = sanitiseString(newValue);
},
});
}
}
And let's make sure it works as you expect. Let's have sanitiseString
actually do something:
const sanitiseString = (valToSanitise: string) => {
return valToSanitise+"!";
}
And now for our class:
class UserInput {
constructor(propOne: string, propTwo: string, propThree: number) {
this.propOne = propOne;
this.propTwo = propTwo;
this.propThree = propThree;
}
@Sanitize() propOne: string
@Sanitize() propTwo: string
propThree: number
}
And finally let's see if it works:
const inputOne = new UserInput('input 1, prop 1', 'input 1, prop 2', 1)
const inputTwo = new UserInput('input 2, prop 1', 'input 2, prop 2', 2)
console.log(inputOne.propOne, inputOne.propTwo, inputOne.propThree)
console.log(inputTwo.propOne, inputTwo.propTwo, inputTwo.propThree);
// GOOD OUTPUT
// [LOG]: "input 1, prop 1!", "input 1, prop 2!", 1
// [LOG]: "input 2, prop 1!", "input 2, prop 2!", 2
Looks good. Each instance of UserInput
has its own sanitized propOne
and propTwo
properties.