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
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 = ( |
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
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 { |
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"; |
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"; |
Putting everything together, we can decorate at will.
$ ts-node arbitrary/main.ts |
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) { |
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"; |
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"; |
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 |
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.
Legal
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.