Deep introduction to accessor decorators in TypeScript

; Date: Tue Feb 15 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. Accessor decorators are applied to accessor method definitions in TypeScript, and can observe, or modify data accessed through get/set functions. With care, they can be used for advanced features like runtime data validation.

In this article we will explore the use and development of accessor decorators. An accessor is the get or set method with which we use code to create what are more-or-less properties. That is, typically we use get and/or set with an existing property, but they can also be used separately from a property. Computed values can be delivered through a get function, for example a Circle object could store the radius, and a get area accessor could tell us the area of the circle by computing PI*R**2. To use decorators, they must be enabled in TypeScript, so be sure to review the introduction to decorators article in this series.

Accessor decorators are attached to either a get or set method, whichever occurs first in the text, but affect both methods.

In practice they look like this:

class Example {

    #name: string;

    @Decorator
    set name(n: string) { this.#name = n; }
    get name() { return #name; }

    #width: number;
    #height: number;

    @Decorator
    get area() { return this.#width * this.#height; }
}

In case you aren't aware of this, #fieldName is a new feature in JavaScript, also supported by TypeScript, to enable proper privacy for field data. These private fields can only be accessed from inside the class.

For name the accessor functions are directly related to an existing property of the same name. For area, the accessor function computes its value based on two other properties. This specific example is incomplete because #width and #height cannot have their values set.

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.

Accessor decorator functions in TypeScript

Accessor decorators are attached to accessor methods. The accessor may or may not be associated with a property of the same name.

The accessor decorator function receives three parameters:

  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.

These are the same parameters as for property decorators, but with the addition of the PropertyDescriptor object. With property decorators we ran into a couple limitations because they do not receive this object. That makes accessor decorators useful for cases where the application must have this descriptor.

The definition of PropertyDescriptor is this:

interface PropertyDescriptor {
    configurable?: boolean;
    enumerable?: boolean;
    value?: any;
    writable?: boolean;
    get?(): any;
    set?(v: any): void;
}

We'll use this object several ways when implementing decorators.

Simple example of Accessor Decorators in TypeScript

Let's start the exploration with a simple example, that prints the information received by the decorator function.

function LogAccessor(target: Object, propertyKey: string,
                    descriptor: PropertyDescriptor) {

    console.log(`LogAccessor`, {
        target, propertyKey, descriptor
    });
}

class Simple {

    #num: number;

    @LogAccessor
    set num(w: number) { this.#num = w; }
    get num() { return this.#num; }
}

An accessor decorator is attached to one of those two methods. It is not allowed to add a decorator to both. Instead, you attach a decorator to the first of the pair which appears in document order.

If you attach a decorator to both, you will get this error message:

error TS1207: Decorators cannot be applied to multiple get/set accessors of the same name.

So, don't do that.

To test this code, add the following:

const s1 = new Simple();
const s2 = new Simple();

s1.num = 1;
s2.num = 1;
console.log(`${s1.num} ${s2.num}`);

s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);

s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);

s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);

s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);

s1.num = s1.num + s2.num;
s2.num = s1.num + s2.num;
console.log(`${s1.num} ${s2.num}`);

And, we can run it to see the output:

$ npx ts-node lib/accessors/first.ts 
LogAccessor {
  target: {},
  propertyKey: 'num',
  descriptor: {
    get: [Function: get num],
    set: [Function: set num],
    enumerable: false,
    configurable: true
  }
}
1 1
2 3
5 8
13 21
34 55
89 144

Kudos if you recognize the mathematical sequence that is computed here.

In any case, we see that the target is an empty object, and the propertyKey is the name of the property. The descriptor contains get and set functions, marks it as non-enumerable and as configurable. The contents of the get and set fields are the functions we wrote in the code.

What descriptor object do we get with a computed accessor function? To find out, let's create a new simple example:

function LogAccessor(target: Object,
    propertyKey: string,
    descriptor: PropertyDescriptor) { .. as above }

class Rectangle {
    #width: number;
    #height: number;

    @LogAccessor
    get area() { 
        return this.#width * this.#height;
    }

    constructor(width: number, height: number) {
        this.#width = width;
        this.#height = height;
    }
}

const r1 = new Rectangle(3, 5);
console.log(r1.area);

The Rectangle class does not have a property named area, and the value for the get area function is computed from width and height values.

Running it we get:

$ npx ts-node lib/accessors/rectangle.ts 
LogAccessor {
  target: {},
  propertyKey: 'area',
  descriptor: {
    get: [Function: get area],
    set: undefined,
    enumerable: false,
    configurable: true
  }
}
15

In this case, the generated PropertyDescriptor only has a get function, and is otherwise the same as the previous example. That's to be expected due to creating only a get function.

Spying on accessor activity in a class

As a first exploration of what we can do with the PropertyDescriptor, let's implement a decorator that prints get/set activity through the accessor.

function AccessorSpy<T>() {
    return function (target: Object, propertyKey: string,
                    descriptor: PropertyDescriptor) {

        const originals = {
            get: descriptor.get,
            set: descriptor.set
        };
        if (originals.get) {
            descriptor.get = function (): T {
                const ret: T = originals.get.call(this);
                console.log(`AccessorSpy get ${String(propertyKey)}`, ret);
                return ret;
            };
        }
        if (originals.set) {
            descriptor.set = function(newval: T) {
                console.log(`AccessorSpy set ${String(propertyKey)}`, newval);
                originals.set.call(this, newval);
            };
        }
    }
}

This is copied from the (github.com) decorator-inspectors package in the repository.

We are using Generics syntax to pass in the data type the accessor should use. This decorator is meant to be used with any accessor, so we must use the correct data type.

We also respect the existing accessor functions. We save both set and get into the originals object. Then, for both, we replace any existing function with a function which invokes the original, then prints its result.

This replacement function requires that, when the original function is called, that this has the correct value. The value for this must be the object instance against which the getter or setter accessor was invoked. After several variations -- for example, arrow functions did not work as the replacement function -- the pattern shown here was found to work correctly.

The value for this inside the inner function is related to the inner function. But, the descriptor.get and descriptor.set replacement functions, as well as originals.get and originals.set, must execute with this set to the correct object instance. Using the call method lets us invoke a function while setting its value for this.

To test this:

class ToSpy {
    #num: number;

    @AccessorSpy<number>()
    set num(w: number) { this.#num = w; }
    get num() { return this.#num; }

}

const tsp1 = new ToSpy();
const tsp2 = new ToSpy();

tsp1.num = 1;
tsp2.num = 2;
console.log(`${tsp1.num} ${tsp2.num}`);

tsp1.num = tsp1.num + tsp2.num;
tsp2.num = tsp1.num + tsp2.num;
console.log(`${tsp1.num} ${tsp2.num}`);

tsp1.num = tsp1.num + tsp2.num;
tsp2.num = tsp1.num + tsp2.num;
console.log(`${tsp1.num} ${tsp2.num}`);

tsp1.num = tsp1.num + tsp2.num;
tsp2.num = tsp1.num + tsp2.num;
console.log(`${tsp1.num} ${tsp2.num}`);

We want to be certain that, as we set the value of a property, that it only affects the given object instance, and that each object instance has distinct values.

When executed we get this:

$ npx ts-node lib/accessors/spy.ts 
AccessorSpy set num 1
AccessorSpy set num 2
AccessorSpy get num 1
AccessorSpy get num 2
1 2
AccessorSpy get num 1
AccessorSpy get num 2
AccessorSpy set num 3
AccessorSpy get num 3
AccessorSpy get num 2
AccessorSpy set num 5
AccessorSpy get num 3
AccessorSpy get num 5
3 5
AccessorSpy get num 3
AccessorSpy get num 5
AccessorSpy set num 8
AccessorSpy get num 8
AccessorSpy get num 5
AccessorSpy set num 13
AccessorSpy get num 8
AccessorSpy get num 13
8 13
AccessorSpy get num 8
AccessorSpy get num 13
AccessorSpy set num 21
AccessorSpy get num 21
AccessorSpy get num 13
AccessorSpy set num 34
AccessorSpy get num 21
AccessorSpy get num 34
21 34

Indeed, we can see that the AccessorSpy function gives us a nice view into the get/set activity of this object. And, we see that the values remain distinct between the two instances.

Controlling the enumerable setting using an accessor decorator

Some accessors we want to treat as any other property. So far the enumerable field of PropertyDescriptor has been false. But what if we want the value returned by an accessor to be included in the fields scanned by a for...in or for..of loop, for instance. This means setting enumerable to true.

export function Enumerable(val: boolean) {
    return (target: Object, propertyKey: string,
        descriptor: PropertyDescriptor)  => {

        if (typeof val !== 'undefined') {
            descriptor.enumerable = val;
        }
    }
}

class SetEnumerable {

    #num: number;

    @LogAccessor
    @Enumerable(true)
    @LogAccessor
    @AccessorSpy<number>()
    set num(w: number) { this.#num = w; }
    get num() { return this.#num; }

}

const en1 = new SetEnumerable();

en1.num = 1;
for (let key in en1) {
    console.log(`en1 ${key} ${en1[key]}`);
}

Okay, we may have gone a little decorator-happy. But, we using our tools to print useful information about each step.

The Enumerable decorator simply sets the enumerable flag in the descriptor. We pass in true or false, and it is very simple. We've used LogAccessor both before and after to make sure to see that the setting was changed. And, we're using AccessorSpy to trace activity on the num accessor.

We then generate an instance, and set a value. The for..in loop lets us know, practically, whether the accessor is enumerable. If it is, num will show up as one of the keys scanned in the loop.

$ npx ts-node lib/accessors/enumerable.ts 
LogAccessor {
  target: {},
  propertyKey: 'num',
  descriptor: {
    get: [Function (anonymous)],
    set: [Function (anonymous)],
    enumerable: false,
    configurable: true
  }
}
LogAccessor {
  target: {},
  propertyKey: 'num',
  descriptor: {
    get: [Function (anonymous)],
    set: [Function (anonymous)],
    enumerable: true,
    configurable: true
  }
}
AccessorSpy set num 1
AccessorSpy get num 1
en1 num 1

And, indeed, the second LogAccessor output shows that enumerable is set to true. Then, at the bottom we see that the printout from inside the loop is made, indicating that num was returned as one of the keys. To verify this, set Enumerable(false) and rerun the script to ensure that last line is not printed when enumerable is false.

Simple runtime data validation using accessor decorators

What we just proved is an accessor decorator can override the get/set function with our code which will execute at runtime. We used this to spy on data as it went in and out of the property.

But, there is more we can do with this new found power. Namely, we can implement runtime data validation.

function Validate<T>(validator: Function) {
    return (target: Object, propertyKey: string,
        descriptor: PropertyDescriptor) => {
        
        const originals = {
            get: descriptor.get,
            set: descriptor.set
        };
        if (originals.set) {
            descriptor.set = function(newval: T) {
                console.log(`Validate set ${String(propertyKey)}`, newval);
                if (validator) {
                    if (!validator(newval)) {
                        throw new Error(`Invalid value for ${propertyKey} -- ${newval}`);
                    }
                }
                originals.set.call(this, newval);
            };
        }
    }
}

class CarSeen {

    #speed: number;

    @Validate<number>((speed: number) => {
        console.log(`Validate speed ${speed}`);
        if (typeof speed !== 'number') return false;
        if (speed < 10 || speed > 65) return false;
        return true;
    })
    set speed(speed) {
        console.log(`set speed ${speed}`);
        this.#speed = speed; }
    get speed() { return this.#speed; }

}

Validate is a decorator factory which takes a function we will use for validation. In the inner function we are only overriding the set accessor. That's because we want to ensure that an incorrect value never arrives in this property.

Inside our set function, if the validator function exists we call it supplying the candidate value. If that returns true, we say all is good and go ahead and call the original setter, otherwise we throw an error.

We added console.log statements everywhere to ensure all the steps are happening as expected.

To test it add the following to the script:

const cs1 = new CarSeen();
cs1.speed = 22;
console.log(cs1.speed);

cs1.speed = 33;
console.log(cs1.speed);

cs1.speed = 44;
console.log(cs1.speed);

cs1.speed = 55;
console.log(cs1.speed);

cs1.speed = 66;
console.log(cs1.speed);

When executed we get this:

$ npx ts-node lib/accessors/validation.ts 
Validate set speed 22
Validate speed 22
set speed 22
22
Validate set speed 33
Validate speed 33
set speed 33
33
Validate set speed 44
Validate speed 44
set speed 44
44
Validate set speed 55
Validate speed 55
set speed 55
55
Validate set speed 66
Validate speed 66
.../simple/lib/accessors/validation.ts:16
                        throw new Error(`Invalid value for ${propertyKey} -- ${newval}`);
                              ^
Error: Invalid value for speed -- 66

We could set any speed we want, until setting a value that's outside the specified range.

This is runtime data validation. We assigned an invalid value, and the validation decorator made sure to prevent that value from polluting the property. Follow the messages and you see that every step is executed. For our invalid value, the first two steps executed, but, because the exception was thrown, the last step was not. Therefore the value of the property was not modified.

Summary

We've learned quite a bit about accessor decorators, from creating and using them, to the basis for automated run-time data validation.

Because accessor decorators give us the PropertyDescriptor object there are many things we can do, so long as we do them carefully. In particular, when replacing the get and set functions, we must carefully ensure the replacement functions execute with this set correctly.

This method for data validation is most ripe with possibilities. Other data validation packages require the programmer to explicitly code data validation. That makes it a thing which is easy to forget to do, right? With this approach you'd attach decorators, then for every assignment to the property the validations are run, and you're assured that insane data values cannot get into any protected property.

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)