This post takes an in-depth look at property decorators. It examines their signature, provides sample usage, and exposes a common antipattern. Reading the previous posts in the series is encouraged but not necessary.

The Series so Far

  1. Decorator Introduction
  2. JavaScript Foundation
  3. Reflection
  4. Parameter Decorators
  5. Property Decorators

These posts are planned but not written yet:

  • Method Decorators
  • Class Decorators

Code

You can view the code related to this post under the post-05-property-decorators tag.

Overview

Property decorators are very similar to parameter decorators in that they're only able to observe declarations (or rather, should only observe declarations). The official docs state

a property decorator can only be used to observe that a property of a specific name has been declared for a class.

Property decorators ignore any return, underscoring their inability to affect the decorated properties. Similar to parameter decorators, property decorators can be used in tandem with other decorators to define extra information about the property. By themselves, their effectiveness is limited. Logging property data seems to be the best use for a property decorator by itself.

A widely used antipattern is to update a property descriptor on target in a property decorator. This wreaks havoc on all sorts of things. Instead, property decorators should set metadata that can be consumed elsewhere. Don't use them to do too much.

Signature

signature.ts
1
2
3
4
type PropertyDecoratorType = (
target: any,
propertyKey: string | symbol,
) => void;

This example is used to explain the signature.

signature-example.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function DecoratedProperty(
target: any,
propertyKey: string | symbol,
) {
// do nothing
}

class TargetDemo {
@DecoratedProperty
public foo: string = "bar";
}

target: any

target is the object that owns the decorated property. target in the example is TargetDemo.

propertyKey: string | symbol

propertyKey is the name of the decorated property. It could also be a Symbol, depending on how the property is defined on the object. propertyKey in the example is foo.

Usage

As property decorators do not affect the underlying object, their primary use is to create and attach metadata. Consuming said metadata involves other decorators, so it's skipped here. The example below illustrates an easy way to attach metadata to properties.

arbitrary/constants.ts
1
export const PROPERTY_METADATA_KEY = Symbol("propertyMetadata");

First we define the metadata key and export it for anything to import.

arbitrary/interfaces.ts
1
2
3
4
5
6
7
8
export interface ISinglePropertyMetadata {
name?: string;
description?: string;
}

export interface IAllPropertyMetadata {
[key: string | symbol]: ISinglePropertyMetadata;
}

This defines ISinglePropertyMetadata and IAllPropertyMetadata to streamline manipulation. It's usually better to have types to rely on.

arbitrary/PropertyMetadata.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import "reflect-metadata";

import { PROPERTY_METADATA_KEY } from "./constants";
import { ISinglePropertyMetadata } from "./interfaces";

export function PropertyMetadata(updates: ISinglePropertyMetadata) {
return (target: any, propertyKey: string | symbol) => {
// Pull the existing metadata or create an empty object
const allMetadata = (
Reflect.getMetadata(PROPERTY_METADATA_KEY, target)
||
{}
);
// Ensure allMetadata has propertyKey
allMetadata[propertyKey] = (
allMetadata[propertyKey]
||
{}
);
// Update the metadata with anything from updates
// tslint:disable-next-line:forin
for (const key of Reflect.ownKeys(updates)) {
allMetadata[propertyKey][key] = updates[key];
}
// Update the metadata
Reflect.defineMetadata(
PROPERTY_METADATA_KEY,
allMetadata,
target,
);
}
}

The PropertyMetadata decorator updates both a property's metadata name and description. It consumes an ISinglePropertyMetadata object to load the values.

arbitrary/main.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import "reflect-metadata";

import { PROPERTY_METADATA_KEY } from "./constants";
import { PropertyMetadata } from "./PropertyMetadata";

class Demo {
@PropertyMetadata({
name: "foo",
})
public foo: any;
@PropertyMetadata({
description: "property bar",
})
public bar: any;
@PropertyMetadata({
name: "baz",
description: "property baz",
})
public baz: any;
}

// The metadata is not defined on the class
console.log(
"Class property metadata:",
Reflect.getMetadata(PROPERTY_METADATA_KEY, Demo),
);
// It's defined on an instance
const demo = new Demo();
console.log(
"Instance property metadata:",
Reflect.getMetadata(PROPERTY_METADATA_KEY, demo),
);

Putting everything together, we can decorate at will.

$ ts-node arbitrary/main.ts
Class property metadata: undefined
Instance property metadata: { foo: { name: 'foo' },
bar: { description: 'property bar' },
baz: { name: 'baz', description: 'property baz' } }

Antipattern

Property decorators cannot modify the owning object as their return is ignored. Ergo any changes made on target are actually made globally. You might have seen this common property decorator example; it defines a property on target using the decorator's scope to maintain state (note that example will not work in strict mode). However, for a single class, the decorator is only called once. This means any instance of the class reuses the same decorator scope, essentially changing an instance property into a static property that can be updated.

Example

This will make more sense with an example. Rather than rehash the copypasta that pops up everywhere, I applied the logic to a (possibly?) common use-case. Many classes include numeric properties; many of those properties should not fall below a minimum value. We can erroneously solve this problem using something like the following.

MinimumValue.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export function MinimumValue(min: number = 0) {
// Scope the value to be reused
let value: number;
return (target: any, propertyKey: string | symbol) => {
// Store the definition result
const update = Reflect.defineProperty(
target,
propertyKey,
{
configurable: true,
enumerable: true,
get: () => {
// Return the scoped value
return value;
},
set: (newValue: number) => {
// Update the scoped value with max(newValue, min)
value = (
newValue >= min
? newValue
: min
);
}
},
);
// If the update failed, something went wrong
if (!update) {
// Kill everything
throw new Error("Unable to update property");
}
}
}

This factory defines propertyKey on target with the newly created property descriptor, using the scoped value.

HasDecoratedProperty.ts
1
2
3
4
5
6
import { MinimumValue } from "./MinimumValue";

export class HasDecoratedProperty {
@MinimumValue(0)
public currentValue: number;
}

This class simply applies the decorator to its currentValue property.

main.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { HasDecoratedProperty } from "./HasDecoratedProperty";

// Pick a set of values
for (const newValue of [-10, 10, 20]) {
// Create a new instance
const demo = new HasDecoratedProperty();
// Add a basic linebreak
if (newValue > -10) {
console.log("---");
}
// Log the current value
console.log("Current value:", demo.currentValue);
// Update the value
console.log(`Attempting to set demo.currentValue = ${newValue}`);
demo.currentValue = newValue;
// Log the current value
console.log("Current value:", demo.currentValue);
}
// Add a basic linebreak
console.log("---");
// Create a new instance
const demo1 = new HasDecoratedProperty();
// Update its value
console.log("Setting demo1.currentValue = -10");
demo1.currentValue = -10;
console.log("demo1.currentValue:", demo1.currentValue);
// Create a new instance
const demo2 = new HasDecoratedProperty();
// Update its value
console.log("Setting demo2.currentValue = 20");
demo2.currentValue = 20;
// Compare the results
console.log("demo1.currentValue:", demo1.currentValue);
console.log("demo2.currentValue:", demo2.currentValue);

Attempting to use the decorator will present several issues. The first, demonstrated in the for loop, is that value recycles state even though we've created a new object. This is because the decorator is only run once, the first time the class is loaded (I think; if I'm wrong I'd love to know). The second, demonstrated with demo1 and demo2, shows that value is actually a singleton. Changing it in one instance changes it everywhere.

$ ts-node --project tsconfig.json main.ts
Current value: undefined
Attempting to set demo.currentValue = -10
Current value: 0
---
Current value: 0
Attempting to set demo.currentValue = 10
Current value: 10
---
Current value: 10
Attempting to set demo.currentValue = 20
Current value: 20
---
Setting demo1.currentValue = -10
demo1.currentValue: 0
Setting demo2.currentValue = 20
demo1.currentValue: 20
demo2.currentValue: 20

Solution

A full solution involves other decorators and is therefore outside the scope of this post (I'll add a link to the full example when I finish it). The gist of the solution is to combine class and property decorators, similar to combining parameter and method decorators. The property decorator sets metadata that the class decorator consumes.

Recap

Property decorators are great at adding extra information about properties at runtime. They can't do a whole more. Property decorators are often used in combination with other decorators to perform new actions at runtime.

The TS logo is a modified @typescript avatar; I turned the PNG into a vector. I couldn't find the original and I didn't see any licensing on the other art, so it's most likely covered by the TypeScript project's Apache 2.0 license. Any code from the TS project is similarly licensed.

If there's a problem with anything, my email's in the footer. Stay awesome.