/ TypeScript

TypeScript Decorators: Introduction

This post serves as introduction to TypeScript decorators. It looks at basic decorators, decorator factories, and decorator composition. You should have some familiarity with TypeScript and some object-oriented programming experience.

The Series so Far

  1. Decorator Introduction
  2. JavaScript Foundation
  3. Reflection
  4. Parameter Decorators
  5. Property Decorators
  6. Method Decorators
  7. Class Decorators

Eventual Topics:

  • Where Decorators Work
  • Decorating Instance Elements vs. Static Elements
  • Examples
    • Pairing Parameter Decorators with Method Decorators
    • Pairing Property Decorators with Class Decorators

Code

You can view the code related to this post under the post-01-decorator-intro tag.

Decorators

The decorator pattern modifies instances of existing objects without affecting the root object or siblings. Typically the pattern extends a base interface by toggling features, setting attributes, or defining roles. Instances of the object being decorated should usually be able to interact, but they don't have to have identical interfaces. Like many foundational patterns, no one agrees about the Platonic decorator.

TypeScript provides experimental decorator support. The ECMAScript decorator proposal has reached stage 2, so we could see them in vanilla JS eventually. TypeScript provides class, method, parameter, and property decorators. Each can be used to observe the decorated objects (mentioned heavily in the docs). All but the parameter decorator can be used to modify the root object.

TypeScript decorators also provide some mixin support. Without true multiple inheritance in JavaScript, combining features can lead to obscenely long prototype chains. TypeScript decorators alleviate that issue by adding behavior at runtime on top of normal inheritance.

Configuration

To gain decorator functionality, you'll have to pass a few new options to the TypeScript compiler.

  • target: The docs mention some issues below ES5 (ctrl+f ES5). I tend to run ESNext while developing.
  • experimentalDecorators: This is what enables the functionality.
  • emitDecoratorMetadata: This is another expermental feature that provides decorator metadata.

You can either include the options by hand every time

$ tsc --target 'ESNext' --experimentalDecorators --emitDecoratorMetadata

or you can add them to your tsconfig.json once.

tsconfig.json
1
2
3
4
5
6
7
{
"compilerOptions": {
"target": "ESNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}

Simple Example

First we need to define several decorators. Each signature was taken from the official docs and will be explained more later (but maybe not this post).

decorators/ClassDecorator.ts
1
2
3
4
5
export function ClassDecorator(
constructor: (...args: any[]) => any,
) {
console.log(`Decorating ${constructor.name}`);
}
decorators/MethodDecorator.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
export function MethodDecorator(
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
console.log(
`Decorating method ${propertyKey}` +
` from ${target.constructor.name}`,
);
}
decorators/ParameterDecorator.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export function ParameterDecorator(
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) {
console.log(
`Decorating parameter ${propertyKey}` +
` (index ${parameterIndex})` +
` from ${target.constructor.name}`,
);
}
decorators/PropertyDecorator.ts
1
2
3
4
5
6
7
8
9
export function PropertyDecorator(
target: any,
propertyKey: string | symbol,
) {
console.log(
`Decorating property ${propertyKey}` +
` from ${target.constructor.name}`,
);
}

Next we'll need to consume the decorators. The decorators are placed before the object they modify, e.g. @ClassDecorator class Foo {}. You could use any of the decorators on any object, but you probably won't see great results unless you hit something like their intended targets. Do note that method decorators are used to modify both normal methods and (g|s)etter methods.

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
import { ClassDecorator } from "./decorators/ClassDecorator";
import { MethodDecorator } from "./decorators/MethodDecorator";
import { ParameterDecorator } from "./decorators/ParameterDecorator";
import { PropertyDecorator } from "./decorators/PropertyDecorator";

@ClassDecorator
class Demo {
@PropertyDecorator
public foo: string = "foo";

constructor() {
console.log("Simple class initialized");
this.writeGreeting();
}

@MethodDecorator
public get bar() {
return "bar";
}

@MethodDecorator
public writeGreeting(
@ParameterDecorator public greeting: string = "Hello, world",
) {
console.log(greeting);
}
}

const demo = new Demo();
$ ts-node main.ts
Decorating property foo from Demo
Decorating method bar from Demo
Decorating parameter writeGreeting (index 0) from Demo
Decorating method writeGreeting from Demo
Decorating Demo
Simple class initialized
Hello, world

The execution order is explained in the docs; to summarize,

  1. instance parameter, method, and property decorators;
  2. static parameter, method, and property decorators;
  3. constructor parameter decorators; and
  4. class decorators.

Decorator Factories

Decorators have well-defined signatures without room for extension. To pass new information into the decorators, we can use the factory pattern. A factory provides a uniform creation interface whose details are delegated to and managed by children.

decorators/Decorator.ts
1
2
3
4
5
export function Decorator(type: string) {
return (...args: any[]) => {
console.log(type, args);
};
}

In this example, Decorator takes a string as input and creates a Function. Changing the input will create a new Function, but all of the Functions log the original input string followed by an array containing the args that the child was called with.

$ ts-node
> import { Decorator } from "./typescript/factories/decorators/Decorator.ts";
{}
> const foo = Decorator("foo")
undefined
> foo(1, 2, 3)
foo [ 1, 2, 3 ]
undefined
> const bar = Decorator("bar")
undefined
> bar(true, false)
bar [ true, false ]
undefined

We can use this decorator everywhere thanks to rest parameters.

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
import { Decorator } from "./decorators/Decorator";

@Decorator("class")
class Demo {
@Decorator("property")
public foo: string = "foo";

constructor() {
console.log("Simple class initialized");
this.writeGreeting();
}

@Decorator("accessor")
public get bar() {
return "bar";
}

@Decorator("method")
public writeGreeting(
@Decorator("parameter") public greeting: string = "Hello, world",
) {
console.log(greeting);
}
}

const demo = new Demo();
$ ts-node main.ts
property [ Demo {}, 'foo', undefined ]
accessor [ Demo {},
'bar',
{ get: [Function: get bar],
set: undefined,
enumerable: false,
configurable: true } ]
parameter [ Demo {}, 'writeGreeting', 0 ]
method [ Demo {},
'writeGreeting',
{ value: [Function: writeGreeting],
writable: true,
enumerable: false,
configurable: true } ]
class [ [Function: Demo] ]
Simple class initialized
Hello, world

While this example was fairly simple, decorator factories are capable of much more. Anything you pass to the factory can be used to assemble the decorator. As the decorator's return is used by everything except for parameter decorators, you can customize the instance using anything in the scope. Decorators aren't limited to building up; they can also tear down.

decorators/MaskMethod.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export function MaskMethod(hide: boolean) {
return (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) => {
if (hide) {
return {
get: undefined,
};
}
return descriptor;
};
}

This decorator can hide methods at run time by tweaking the property descriptor for the method.

other-main.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { MaskMethod } from "./decorators/MaskMethod";

class MaskableMethods {
@MaskMethod(true)
public foo() {
console.log("foo");
}

@MaskMethod(false)
public bar() {
console.log("bar");
}
}

const demoMaskableMethods = new MaskableMethods();

for (const key of ["foo", "bar"]) {
console.log(key, demo[key]);
}
$ ts-node other-main.ts
foo undefined
bar bar() {
console.log("bar");
}

Composition

Function composition is a very useful tool. It requires two functions, f: A → B and g: C → D, with some conditions on their domains and ranges. To compose f with g, i.e. f(g(x)), D must be a subset of A, i.e. the input of f must contain the output of g.

This is much simpler in code. For the most part, we can compose f with g when g's return value is identical to f's input (completely ignoring containment because that gets messy). As we've seen, decorators seem to return a single object while they consume an array of arguments. That would suggest they cannot be composed. However, decorators aren't actually being called and run on the stack by themselves. TypeScript surrounds the decorator calls with several other things behind the scenes, which, rather magically, means decorators can be composed with other decorators.

decorators/Enumerable.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export function Enumerable(enumerable: boolean = true) {
console.log(
`Creating ${enumerable ? "" : "non-"}` +
`enumerable property factory`,
);
return function decorator(
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
console.log(
`Making ${propertyKey}` +
` ${enumerable ? "" : "non-"}enumerable`,
);
descriptor.enumerable = enumerable;
return descriptor;
};
}

This decorator updates the enumerable property of methods, showing/hiding them when iterating over the object. To illustrate how it works, this class has two methods that are only decorated once. To illustrate composition, another two are decorated twice.

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
import { Enumerable } from "./decorators/Enumerable";

class Demo {

@Enumerable(true)
public isEnumerable() {
// do nothing
}

@Enumerable(true)
@Enumerable(false)
public resultIsEnumerable() {
// do nothing
}

@Enumerable(false)
public isNotEnumerable() {
// do nothing
}

@Enumerable(false)
@Enumerable(true)
public resultIsNotEnumerable() {
// do nothing
}
}

const demo = new Demo();

// tslint:disable-next-line:forin
for (const key in demo) {
console.log(key);
}
$ ts-node main.ts
Creating enumerable property factory
Making isEnumerable enumerable
Creating enumerable property factory
Creating non-enumerable property factory
Making resultIsEnumerable non-enumerable
Making resultIsEnumerable enumerable
Creating non-enumerable property factory
Making isNotEnumerable non-enumerable
Creating non-enumerable property factory
Creating enumerable property factory
Making resultIsNotEnumerable enumerable
Making resultIsNotEnumerable non-enumerable
isEnumerable
resultIsEnumerable

The first decorator factory builds its factory first, but executes the factory last. The second decorator's build and execution are sandwiched between the two components of the first. The more decorators chained, the deeper the nesting. To resolve the composition, each call must be finished in turn.

Recap

Decorators provide a way for children to manage their responsibilities and options. TypeScript supports decorators (experimentally for now) with a very simple interface. When basic decorators don't cut it, the vanilla options can be extended with decorator factories. Composing decorators with decorators allows us to combine multiple decorators on the same object.

I think I'm going to look at the generated JavaScript next. Don't hold me to that.

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.

CJ Harries

I did a thing once. Change "blog." to "cj@" and you've got my email. All these opinions are mine and might not be shared by clients or employers.

Read More