Tags: TypeScript
Decorators allow us to add additional information to classes or methods in TypeScript, and are similar to annotations such as in Java. Property decorators are applied to property definitions in TypeScript, and can observe them.
In this article we will explore the use and development of property decorators. These decorators are attached to properties, or fields, in a TypeScript class, and they are able to observe that a property has been declared for a specific class. To use decorators, they must be enabled in TypeScript, so be sure to review the introduction to decorators article in this series.
In practice they look like this:
class ContainingClass {
@Decorator(?? optional parameters)
name: type;
}
This article is part of a series:
- Introduction to Decorators
- Class Decorators
- Property Decorators This article
- Accessor Decorators
- Method Decorators
- Parameter Decorators
- Hybrid Decorators
- Using Reflection and Reflection API with Decorators
- Runtime data validation using Decorators
To use decorators, two features must be enabled in TypeScript, so be sure to review the introduction to decorators article in this series.
Property decorator functions in TypeScript
Property decorators are attached to properties in a class definition. In JavaScript, properties are a value that's associated with an object. The simplest property is just a field declared in the object.
The property decorator function receives two arguments:
- Either the constructor function of the class for a static member, or the prototype of the class for an instance member.
- A string giving the name of the property
This defines a required signature for property decorator functions. Notice that we are not given a pointer to the PropertyDescriptor object related to the property. The TypeScript documentation explains this is because of details in how properties are instantiated. The result is, therefore, an inability to do anything other than observe that a property by this name exists.
Accessor decorators do receive the PropertyDescriptor object. If your application needs that object, then focus on using acessors.
Let's try a simple example of a class decorator, which simply prints the data it is given.
function logProperty(target: Object, member: string): any {
console.log(`PropertyExample logProperty ${target} ${member}`);
}
class PropertyExample {
@logProperty
name: string;
}
const pe = new PropertyExample();
if (!pe.hasOwnProperty('name')) {
console.log(`No property 'name' on pe`);
}
pe.name = "Stanley Steamer";
if (!pe.hasOwnProperty('name')) {
console.log(`No property 'name' on pe`);
}
console.log(pe);
The logProperty
function implements the signature required for property decorators.
While experimenting with this, we learned that even though the name
property was clearly defined in this class, the first call to hasOwnProperty
returned false
indicating the property did not exist. Namely, study this output:
$ node dist/property.js
PropertyExample logProperty [object Object] name
No property 'name' on pe
PropertyExample { name: 'Stanley Steamer' }
Here's what happened:
- The first line of the output is from inside the decorator showing what we receive, demonstrating that decorator function executed.
- But the next line of output occurs because
pe.hasOwnProperty('name')
returnsfalse
, indicating that the property does not exist. ThehasOwnProperty
function is derived from theObject
class, and indicates whether the object has a property by that name. - The code then assigns a value to
pe.name
. - After that,
hasOwnProperty
says the property exists. - The property value is printed by
console.log
.
Does this tell us that the property won't exist until data has been assigned to it?
To explore this a little further, let's try to retrieve the PropertyDescriptor object:
function logProperty(target: Object, member: string): any {
console.log(`PropertyExample logProperty ${target} ${member}`);
}
function GetDescriptor() {
return (target: Object, member: string) => {
const prop = Object.getOwnPropertyDescriptor(target, member);
console.log(`Property ${member} ${prop}`);
};
}
class Student {
@GetDescriptor()
year: number;
}
const stud1 = new Student();
console.log(Object.getOwnPropertyDescriptor(stud1, 'year'));
stud1.year = 2022;
console.log(Object.getOwnPropertyDescriptor(stud1, 'year'));
The Object
class has two functions, getOwnPropertyDescriptor
and defineProperty
, related to the PropertyDescriptor object for a property. This script calls getOwnPropertyDescriptor
while the decorator is executing, then after an object instance is created, then after a value has been assigned to the property.
Lets run this script:
$ npx ts-node lib/properties/descriptor.ts
Property year undefined
undefined
{ value: 2022, writable: true, enumerable: true, configurable: true }
We cannot get the descriptor, until a value is assigned to the property. That verifies the theory we floated earlier.
The TypeScript documentation has this to say:
NOTE A Property Descriptor is not provided as an argument to a property decorator due to how property decorators are initialized in TypeScript. This is because there is currently no mechanism to describe an instance property when defining members of a prototype, and no way to observe or modify the initializer for a property.
In other words, the property descriptor function executes before the PropertyDescriptor object exists.
Registering property settings with a framework
In the decorator function, we're given a target object, the name of the property, and any parameters passed to the decorator function. We have no means to override or otherwise modify the behavior of the property. What we can do is record data from the decorator, as we did in the example of registering a class with a framework.
For a rationale, consider a framework for data validation. We can attach decorators to properties describing the acceptable values, then the validation framework would use those settings to determine whether a value is acceptable or not.
const registered = [];
function IntegerRange(min: number, max: number) {
return (target: Object, member: string) => {
registered.push({
target, member,
operation: {
op: 'intrange',
min, max
}
});
}
}
function Matches(matcher: RegExp) {
return (target: Object, member: string) => {
registered.push({
target, member,
operation: {
op: 'match',
matcher
}
});
}
}
Here is a pair of property decorator factory functions. The first records a validation operation of ensuring that the value is an integer, within the given range of values. The other operation is a string match against a regular expression. The data for both is recorded into the registered
array.
class StudentRecord {
@IntegerRange(1900, 2050)
year: number;
@Matches(/^[a-zA-Z ]+$/)
name: string;
}
const sr1 = new StudentRecord();
console.log(registered);
The StudentRecord class is using those two against its properties. Then we generate an instance of the class, and print out the registered
array.
In a real validation framework we would use the Reflection Metadata API to store this data into a property. We'll work on this later when we discuss that API.
For now, let's run the application:
$ npx ts-node lib/properties/register.ts
[
{
target: {},
member: 'year',
operation: { op: 'intrange', min: 1900, max: 2050 }
},
{
target: {},
member: 'name',
operation: { op: 'match', matcher: /^[a-zA-Z ]+$/ }
}
]
And, the registered
array does record information which is probably of use to such a framework.
The registered
array is filled with these values whether we instantiate a StudentRecord class instance or not. Comment out the new StudentRecord
line, then rerun the script, and the same data is printed.
What we've proved is it's very easy to record any data we like about the properties in another location. Other functions, in a framework of some kind, can consult that data and do useful things.
The path down the blind alley of using Object.defineProperty
Several tutorial posts on other blogs about the properties decorator suggest using Object.defineProperty
to implement runtime data validation. The flaw with that recommendation is what we just demonstrated -- that the PropertyDescriptor object is not available to a properties decorator function. We need to talk about that incorrect recommendation to use defineProperty
.
Let's start with a decorator function:
function ValidRange(min: number, max: number) {
return (target: Object, member: string) => {
console.log(`Installing ValidRange on ${member}`);
let value: number;
Object.defineProperty(target, member, {
enumerable: true,
get: function() {
console.log("Inside ValidRange get");
return value;
},
set: function(v: number) {
console.log(`Inside ValidRange set ${v}`);
if (v < min || v > max) {
throw new Error(`Not allowed value ${v}`);
}
value = v;
}
});
}
}
This decorator is meant to be used with a numerical property, and to enforce a valid range between min
and max
. It calls defineProperty
with get
/set
functions where the set
function enforces the range. For data storage, the functions store the value in a local variable. This looks simple and straight-forward, does it not?
This code is carefully set up to match decorator functions appearing on the other blogs mentioned earlier. On one of those blogs, there are comments at the bottom pointing out a problem. See if you can spot the error yourself.
To test it out, add the following to the script:
class Student {
@ValidRange(1900, 2050)
year: number;
}
const stud = new Student();
const stud2 = new Student();
stud.year = 1901;
stud2.year = 1911;
console.log(`stud1 ${stud.year} stud2 ${stud2.year}`);
stud.year = 2030;
console.log(`stud1 ${stud.year} stud2 ${stud2.year}`);
// stud.year = 1899;
// console.log(stud.year);
console.log(`stud1 ${stud.year} stud2 ${stud2.year}`);
stud2.year = 2022;
console.log(`stud1 ${stud.year} stud2 ${stud2.year}`);
stud2.year = 2023;
console.log(`stud1 ${stud.year} stud2 ${stud2.year}`);
This defines a class, and generates two instances. We assign values to one or the other instances, and then print out the values. If you want to see data validation in action, comment out the line which assigns 1899
and you'll see it throw an exception.
Instead, let's go ahead and run this:
$ npx ts-node lib/properties/descriptor2.ts
Installing ValidRange on year
Inside ValidRange set 1901
Inside ValidRange set 1911
Inside ValidRange get
Inside ValidRange get
stud1 1911 stud2 1911
Inside ValidRange set 2030
Inside ValidRange get
Inside ValidRange get
stud1 2030 stud2 2030
Inside ValidRange get
Inside ValidRange get
stud1 2030 stud2 2030
Inside ValidRange set 2022
Inside ValidRange get
Inside ValidRange get
stud1 2022 stud2 2022
Inside ValidRange set 2023
Inside ValidRange get
Inside ValidRange get
stud1 2023 stud2 2023
We print out every time the value is set or retrieved. We see that stud1
and stud2
are assigned 1901
and 1911
respectively, but when the two values are printed they are both 1911
. No matter which variable we assign a new value, the other variable shows the same value.
What's happening? We asked you earlier to spot the error. How did you do?
The issue is the data storage inside the decorator function. That function is executed only once per property, in a given class, when the class definition is being constructed. The function is not executed each time an instance of the class is created, only when the definition is created. The local variable, value
, in which the data is stored, is only created that one time. That instance of instance
is inside the stack-frame for the decorator function, which is only executed once per property per class.
What this gets at is the data stored in value
is shared between all instances of properties using @ValidRange
. This is because the data storage for the property, when @ValidRange
is used, is managed by the decorator rather than managed by JavaScript.
In this case we have a class, Student, with a property, year
, where the property is decorated with @ValidRange
. As we demonstrated, the same value is shared between both instances of year
.
To verify this behavior, add the following field to the Student class:
@ValidRange(0, 150)
age: number;
We're setting up another property that is managed by @ValidRange
. Will we see the same data sharing issue? And will the value for age
be the same as for year
?
Make this change:
stud.year = 1901;
stud2.year = 1911;
stud.age = 20;
console.log(`stud1 ${stud.year} ${stud.age} stud2 ${stud2.year} ${stud2.age}`);
This assigns a value for age
in one of the Student instances, then prints the age
for both.
Installing ValidRange on year
Installing ValidRange on age
Inside ValidRange set 1901
Inside ValidRange set 1911
Inside ValidRange set 20
Inside ValidRange get
Inside ValidRange get
Inside ValidRange get
Inside ValidRange get
stud1 1911 20 stud2 1911 20
The values for the year
and age
property are distinct between themselves, but are shared between Student
instances. We only assigned a value once, but notice that the same value is printed for both instances.
To demonstrate even further, generate a new Student instance:
const stud3 = new Student();
Then, do not assign any value to that instance, but add a console.log
statement:
console.log(`stud3 ${stud3.year} ${stud3.age}`);
When the script is executed you'll see this:
stud3 1911 20
The same values are shown in stud3
despite it not having any value assigned to it.
Every instance of the Student.year
property shares the same value, as does every instance of Student.age
. This is because @ValidRange
manages the data storage, rather than JavaScript doing so. The cause behind this is using Object.defineProperty
in an incorrect way. JavaScript does give us lots of tools with which to shoot ourselves in the foot.
When I read those other blog posts about property decorators, it was mind blowing how easy runtime data validation could be. But, the technique shown in those posts are a blind alley, since there is a major flaw in the implementation.
At the time the property decorator function executes, JavaScript has not created the PropertyDescriptor object. It can be powerful to override the get
/set
functions of THAT property descriptor. It is misleading to create your own property descriptor and think that you've reached a goal of runtime data validation.
In our article on accessor decorators, we show a simple way to implement runtime data validation by overriding the get
/set
functions in the correct property descriptor.
Summary
We are able to attach decorators to properties. This means we can record information about the decorators attached to each property, and then do something with that data. But, we were unable to work how to access the PropertyDescriptor and do anything with the get
/set
functions.
The reason for that is due to when the class decorator function executes.
There are many possibilities available if we can override the get
/set
methods in the PropertyDescriptor. But, with properties, that object does not exist until a value is assigned to the property.
We notice that accessor decorator functions do receive the PropertyDescriptor object. We explore what to do with that in Deep introduction to accessor decorators in TypeScript.
What we were able to do is record decorator information in a data structure. As an example, the class-validator
package has decorators like @IsInt
or @Min
or @Max
to validate property values. We know that it must be recording those into a data structure, and when the application invokes the validate
function it must be inspecting that data to know how to validate class instances.