/ TypeScript

TypeScript Decorators: Reflection

This post takes a cursory look at reflection with TypeScript. Its primary focus is how reflection can be used with TypeScript decorators. It introduces Reflect, reflect-metadata, and some miscellaneous related components.

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-03-reflection tag.

Overview

Reflection is the capacity of code to inspect and modify itself while running. Reflection implies (typically) that the code has a secondary interface with which to access everything. JavaScript's eval function is a great example; an arbitrary string is converted (hopefully) into meaningful elements and executed.

Reflect

ECMAScript 2015 added the Reflect global. While might seem like a rehashed Object, Reflect adds some very useful reflection functionality.

ownKeys
 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
// This object will have prop = "cool"
class RootObject {
public prop: string = "cool";
}

// Its prototype will have foo = "bar"
RootObject.prototype = { foo: "bar" } as any;

// Create an instance
const root = new RootObject();

// for...in moves up the prototype chain
// tslint:disable-next-line:forin
for (const key in root) {
console.log(key);
}
// prop
// foo

// hasOwnProperty will prevent this
// but requires an extra conditional
for (const key in root) {
if (root.hasOwnProperty(key)) {
console.log(key);
}
}
// prop

// Reflect.ownKeys solves it in one line
for (const key of Reflect.ownKeys(root)) {
console.log(key);
}
// prop
has
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// The visibility compiles out but whatever
class Demo {
public foo: number = 1;
protected bar: number = 2;
private baz: number = 3;
}

// Create an instance
const demo = new Demo();

console.log(Reflect.has(demo, "foo"));
// true
console.log(Reflect.has(demo, "bar"));
// true
console.log(Reflect.has(demo, "baz"));
// true
console.log(Reflect.has(demo, "qqq"));
// false
deleteProperty
 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
(() => {
"use strict";
const sampleDeleteObject = {
one: 1,
three: 3,
two: 2,
};

// Delete a property with delete
console.log(delete sampleDeleteObject.one);
// true
// Delete a property with Reflect
console.log(Reflect.deleteProperty(sampleDeleteObject, "two"));
// true
console.log(sampleDeleteObject);
// { three: 3 }
// Accidentally try to delete an object
try {
// tslint:disable-next-line:no-eval
console.log(eval("delete sampleDeleteObject"));
} catch (error) {
// do nothing
}
// Accidentally try to delete an object
console.log(Reflect.deleteProperty(sampleDeleteObject));
// true
console.log(sampleDeleteObject);
// { three: 3 }
})();

emitDecoratorMetadata

TypeScript comes with a few experimental reflection features. As before, you'll need to enable them first.

tsconfig.json
1
2
3
4
5
6
7
8
{
"compilerOptions": {
"target": "ESNext",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["./**/*.ts"]
}

To investigate the experimental metadata, we'll need to create a decorator. A logging method decorator was the first thing I typed out.

(Note: If you haven't seen the previous post, you might benefit from a short skim.)

main.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function LogMethod(
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
console.log(target);
console.log(propertyKey);
console.log(descriptor);
}

class Demo {
@LogMethod
public foo(bar: number) {
// do nothing
}
}
const demo = new Demo();
$ tsc --project tsconfig.json
main.js
 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
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
function LogMethod(target, propertyKey, descriptor) {
console.log(target);
console.log(propertyKey);
console.log(descriptor);
}
class Demo {
foo(bar) {
// do nothing
}
}
__decorate([
LogMethod,
__metadata("design:type", Function),
__metadata("design:paramtypes", [Number]),
__metadata("design:returntype", void 0)
], Demo.prototype, "foo", null);
const demo = new Demo();

The __metadata declaration is brand new. It looks similar to the __decorate and __param declarations that we've seen before. It too comes from deep in the compiler.

metadataHelper
1
2
3
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};

__metadata attempts to create a Reflect.metadata factory (more on that soon). It passes key-value pairs to Reflect.metadata, which in turn stashes them away to be accessed later. By default, emitDecoratorMetadata exposes three new properties:

  • design:type: the type of the object being decorated (here a Function)
  • design:paramtypes: an array of types that match either the decorated item's signature or its constructor's signature (here [Number])
  • design:returntype: the return type of the object being decorated (here void 0)

At the bottom of the file in the __decorate call, you'll see the __metadata calls. Everything looks great. Except we're missing an important component.

$ ts-node --print 'Reflect && Reflect.metadata || "whoops"'
whoops

reflect-metadata

The reflect-metadata package aims to extend the available reflection capabilities. While it is an experimental package, it comes recommended in the official docs and sees quite a bit of play. It's also necessary to take advantage of the emitDecoratorMetadata compiler option, as we've just discovered.

Defining new metadata is very easy. reflect-metadata provides both imperative commands and a decorator factory. The decorator factory stores the metadata key-value pair and passes control through.

basic-usage.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
import "reflect-metadata";

class BasicUsage {
constructor() {
// Explicitly define some metadata
// key, value, target, propertyKey
Reflect.defineMetadata("foo1", "bar1", this, "baz");
}

// Define metadata via a decorator
// key, value
@Reflect.metadata("foo2", "bar2")
public baz() {
// do nothing
}
}

const demoBasicUsage = new BasicUsage();

// key, target, propertyKey
console.log(Reflect.getMetadata("foo1", basicUsageDemo, "baz"));
// bar1
console.log(Reflect.getMetadata("foo2", basicUsageDemo, "baz"));
// bar2

Accessing the data is also very easy. We can now grab the emitDecoratorMetadata metadata that we've been trying to get to.

decorator-metadata.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
import "reflect-metadata";

function LogMethod(
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
// Checks the type of the decorated object
console.log(Reflect.getMetadata("design:type", target, propertyKey));
// [Function: Function]
// Checks the types of all params
console.log(Reflect.getMetadata("design:paramtypes", target, propertyKey));
// [[Function: Number]]
// Checks the return type
console.log(Reflect.getMetadata("design:returntype", target, propertyKey));
// undefined
}

class Demo {
@LogMethod
public foo(bar: number) {
// do nothing
}
}
const demo = new Demo();

Example: Validate a Parameter Range

This group of files adds the ability to ensure specific parameters fall within a certain range. RANGE_KEY is shared across files so everything can access the stashed ranges.

constants.ts
1
export const RANGE_KEY = Symbol("validateRange");

When a parameter is decorated, add the range to the owning method's metadata.

RangeParameter.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
import "reflect-metadata";

import { RANGE_KEY } from "./constants";

export function RangeParameter(
min: number = 0,
max: number = 100,
) {
return (
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) => {
// Pull existing metadata (if any)
const existingRanges: { [key: number]: number[] } = (
Reflect.getMetadata(RANGE_KEY, target, propertyKey)
||
{}
);
// Add new value
existingRanges[parameterIndex] = [min, max];
// Store metadata
Reflect.defineMetadata(RANGE_KEY, existingRanges, target, propertyKey);
};
}

This decorates the method that owns the decorated range. When called, it checks for any active ranges. Each watched parameter is checked against the range's endpoints. An error is thrown if the value is out of the range.

ValidateRange.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
35
36
37
import "reflect-metadata";

import { RANGE_KEY } from "./constants";

export function ValidateRange(
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
// Store the original value
const savedValue = descriptor.value;
// Attach validation logic
descriptor.value = (...args: any[]) => {
// Pull the active ranges (if any)
const monitoredRanges: { [key: number]: number[] } = (
Reflect.getOwnMetadata(
RANGE_KEY,
target,
propertyKey,
)
||
{}
);
// Check all monitored ranges
// tslint:disable-next-line:forin
for (const key in Reflect.ownKeys(monitoredRanges)) {
const range = monitoredRanges[key];
const value = args[key];
// Throw error if outside range
if (value < range[0] || value > range[1]) {
throw new Error("Value outside of range");
}
}
// Actually call the function
return Reflect.apply(savedValue, target, args);
};
}

This puts everything together in a simple class.

Sample.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import { RangeParameter } from "./RangeParameter";
import { ValidateRange } from "./ValidateRange";

export class Sample {
// Validate the input ranges
@ValidateRange
public updatePercentage(
// Define a min,max of 0,100
@RangeParameter(0, 100)
newValue: number,
// Does nothing
negative: boolean = false,
) {
console.log(newValue);
}
}

This runs everything, illustrating how a successful update works and catching a failed update.

main.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import "reflect-metadata";

import { RANGE_KEY } from "./constants";
import { Sample } from "./Sample";

// Initialize
const demoSample = new Sample();
// Working value
demoSample.updatePercentage(10);
// Bad value
try {
demoSample.updatePercentage(200);
} catch (error) {
// do nothing
}

Recap

Make sure to skim metadata coverage in the official docs. Reflection tweaks active code. Reflect is a robust tool with applications outside this context. emitDecoratorMetadata emits object type, param type, and return type, when reflect-metadata is loaded. reflect-metadata can easily link disparate portions of your app together via straightfoward key-value matching.

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