Deep introduction to property decorators in TypeScript

; Date: Sat Feb 12 2022

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:

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:

  1. Either the constructor function of the class for a static member, or the prototype of the class for an instance member.
  2. 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:

  1. The first line of the output is from inside the decorator showing what we receive, demonstrating that decorator function executed.
  2. But the next line of output occurs because pe.hasOwnProperty('name') returns false, indicating that the property does not exist. The hasOwnProperty function is derived from the Object class, and indicates whether the object has a property by that name.
  3. The code then assigns a value to pe.name.
  4. After that, hasOwnProperty says the property exists.
  5. 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.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.

Books by David Herron

(Sponsored)