/ TypeScript

TypeScript Decorators: Parameter Decorators

This post takes an in-depth look at parameter decorators. It examines their signature and provides a couple of useful examples. 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
  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-04-parameter-decorators tag.

Overview

Parameter decorators are the most restricted decorators. The official docs state

[a] parameter decorator can only be used to observe that a parameter has been declared on a method.

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

(If you've got a different or novel use for parameter decorators, I'd love to hear about it. Seriously. I'm really curious to see how other devs are using these. My email's in the footer.)

Class Method vs Global Function

An interesting side-effect of decorators is that they (apparently) must be defined on class elements. You can't decorate globals unattached to a class.

class-only.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function DecoratedParameter(
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) {
console.log(target);
console.log(propertyKey);
console.log(parameterIndex);
}

class TargetDemo {
public foo1(baz: any, @DecoratedParameter bar: any) {
console.log("Class method foo");
}
}

function foo2(baz: any, @DecoratedParameter bar: any) {
console.log("Global function foo");
}

const test = new TargetDemo();
test.foo1("class baz", "class bar");
foo2("function baz", "function bar");
$ ts-node class-only.ts
TargetDemo { foo1: [Function] }
foo1
1
Class method foo
Global function foo

Even though we've attempted to decorate the global function foo, it doesn't work. Notice how the decorated logging is only called once, not twice, and only with foo1. I suspect this is related to how all of these things are defined, and I plan to investigate this more in another post.

Signature

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

This example is used to explain the signature.

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

class TargetDemo {
public foo(baz: any, @DecoratedParameter bar: any) {
// do nothing
}
}

target: any

target is the object (not method) that owns the method whose parameter has been decorated. target in the example is TargetDemo, not foo.

propertyKey: string | symbol

propertyKey is the method name (not object name) whose signature has been decorated. It could also be a Symbol, depending on how the method is defined on the object. propertyKey in the example is foo, not TargetDemo.

parameterIndex: number

parameterIndex is the index of the decorated parameter in the signature of the calling method. parameterIndex in the example is 1.

Usage

I spent last week trying to figure out an interesting or useful parameter decorator that functions in a vacuum, i.e. one not used with other decorators (well, not the whole week, just when I wanted to work on a really difficult problem that doesn't seem to have a good solution). I still have nothing. Parameter decorators are triggered when the parameter is declared, but they don't affect anything. We can't observe the parameter's value, because that's attached long after the parameter is decorated. We can't change the state, because that's also not created until long after the parameter is decorated. Long story short, we can define metadata and that's about it.

If you haven't read the reflection post, give it a quick skim. We'll either have to build our own metadata interface in vanilla TypeScript or use the reflect-metadata package. One requires a bunch of extra work totally unrelated to the code we're writing and the other is a simple import.

Once again (I'm getting tired of reiterating this), parameter decorators are observers. We can define metadata, but we're not really able to consume any. Parameter decorators are executed before anything else, so I suppose you could consume other parameter metadata but that's just silly (I'd wager that execution order isn't well-defined across platforms, modules, and standards).

required

The official docs give a very useful example. One of the features TypeScript adds is required arguments, e.g. if I define function foo(bar: string), I can't compile foo(). However, the underlying JavaScript doesn't respect those restrictions. Anything downstream that uses the JavaScript instead of the TypeScript could easily sidestep those restriction (accidentally or not), and there are plenty of ways around them in TypeScript itself.

Using decorators, we can at least note that parameter is required or not. Whether or not something is done with that metadata is outside the scope of parameter decorators, so I'm skipping that here. This is one way to tag them. It's loosely based on the official docs but approaches things differently enough that I'm comfortable calling this my own. Honestly there are only so many way to create an array, add values, and pass it on.

required/constants.ts
1
export const REQUIRED_KEY = Symbol("requiredParameter");

By exporting the Symbol we can use it anywhere we import it (and ensure it's the same everywhere).

required/Required.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
import "reflect-metadata";

import { REQUIRED_KEY } from "./constants";

export function Required(
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) {
// Pull existing parameters for this method or create an empty array
const requiredParameters = (
Reflect.getOwnMetadata(
REQUIRED_KEY,
target,
propertyKey,
)
||
[]
);
// Add this parameter
requiredParameters.push(parameterIndex);
// Ensure regular order
requiredParameters.sort();
// Update the required parameters for this method
Reflect.defineMetadata(
REQUIRED_KEY,
requiredParameters,
target,
propertyKey,
);
}

You don't actually have to sort the array, but the order might not be what you expect (it was reversed the one time I ran it). If you're consuming it via a for...of loop, you really don't have to sort it.

required/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 { REQUIRED_KEY } from "./constants";
import { Required } from "./Required";

class Demo {
public foo(
@Required bar1: any,
bar2: any,
@Required bar3: any,
) {
// do nothing
}
}

// Not defined on the class itself
console.log(Reflect.getMetadata(
REQUIRED_KEY,
Demo,
"foo",
));
// undefined

// Create an instance
const demo = new Demo();
// Defined on instances of the class
console.log(Reflect.getMetadata(
REQUIRED_KEY,
demo,
"foo",
));
// [ 0, 2 ]
$ ts-node required/main.ts
undefined
[ 0, 2 ]

Arbitrary Metadata

I've already written an example adding some validation metadata. The official docs cover pulling existing metadata. You can basically add anything you'd like.

The example below illustrates two different approaches to add specific parameter metadata. You can either create a decorator that takes everything (ParameterMetadata) or chain individual decorators (Name, Description) to attach only the desired information (of course you could tweak ParameterMetadata's signature to request an object and pull name and description out of that instead).

arbitrary/constants.ts
1
2
3
export const PARAMETER_NAME_KEY = Symbol("parameterName");
export const PARAMETER_DESCRIPTION_KEY = Symbol("parameterDescription");
export const PARAMETER_METADATA_KEY = Symbol("parameterMetadata");

First we define the metadata keys and export them for anything to import.

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

export type SignatureMetadataType = IParameterMetadata[];

This defines IParameterMetadata and aliases an array of IParameterMetadata as SignatureMetadataType to streamline manipulation. It's usually better to have types to rely on.

arbitrary/Name.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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import "reflect-metadata";

import { PARAMETER_METADATA_KEY, PARAMETER_NAME_KEY } from "./constants";
import { SignatureMetadataType } from "./interfaces";

export function updateParameterNames(
target: any,
propertyKey: string | symbol,
parameterIndex: number,
name: string,
) {
// Pull the array of parameter names
const parameterNames = (
Reflect.getOwnMetadata(
PARAMETER_NAME_KEY,
target,
propertyKey,
)
||
[]
);
// Add the current parameter name
parameterNames[parameterIndex] = name;
// Update the parameter names
Reflect.defineMetadata(
PARAMETER_NAME_KEY,
parameterNames,
target,
propertyKey,
);
}

export function Name(name: string) {
return (
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) => {
// Update the parameter name metadata
updateParameterNames(
target,
propertyKey,
parameterIndex,
name,
);
// Pull the signature's metadata
const parameterMetadata: SignatureMetadataType = (
Reflect.getOwnMetadata(
PARAMETER_METADATA_KEY,
target,
propertyKey,
)
||
[]
);
// Either
// * update an entry that has a description, or
// * create a new entry with an empty description
if (
parameterMetadata[parameterIndex]
&&
parameterMetadata[parameterIndex].description
) {
parameterMetadata[parameterIndex].name = name;
} else {
parameterMetadata[parameterIndex] = {
description: "",
name,
};
}
// Update the signature metadata
Reflect.defineMetadata(
PARAMETER_METADATA_KEY,
parameterMetadata,
target,
propertyKey,
);
};
}

The Name decorator updates the list of parameter names and also updates the list of signature metadata, since I decided to make things complicated.

arbitrary/Description.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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import "reflect-metadata";

import { PARAMETER_DESCRIPTION_KEY, PARAMETER_METADATA_KEY } from "./constants";
import { SignatureMetadataType } from "./interfaces";

export function updateParameterDescriptions(
target: any,
propertyKey: string | symbol,
parameterIndex: number,
description: string,
) {
// Pull the array of parameter names
const parameterDescriptions = (
Reflect.getOwnMetadata(
PARAMETER_DESCRIPTION_KEY,
target,
propertyKey,
)
||
[]
);
// Add the current parameter name
parameterDescriptions[parameterIndex] = description;
// Update the parameter descriptions
Reflect.defineMetadata(
PARAMETER_DESCRIPTION_KEY,
parameterDescriptions,
target,
propertyKey,
);
}

export function Description(description: string) {
return (
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) => {
// Update the parameter description metadata
updateParameterDescriptions(
target,
propertyKey,
parameterIndex,
description,
);
// Pull the signature's metadata
const parameterMetadata: SignatureMetadataType = (
Reflect.getOwnMetadata(
PARAMETER_METADATA_KEY,
target,
propertyKey,
)
||
[]
);
// Either
// * update an entry that has a name, or
// * create a new entry with an empty name
if (
parameterMetadata[parameterIndex]
&&
parameterMetadata[parameterIndex].name
) {
parameterMetadata[parameterIndex].description = description;
} else {
parameterMetadata[parameterIndex] = {
description,
name: "",
};
}
// Update the signature metadata
Reflect.defineMetadata(
PARAMETER_METADATA_KEY,
parameterMetadata,
target,
propertyKey,
);
};
}

The Description decorator is almost identical to Name. It, rather unsurprisingly, updates descriptions instead of names.

arbitrary/ParameterMetadata.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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import "reflect-metadata";

import { PARAMETER_METADATA_KEY } from "./constants";
import { SignatureMetadataType } from "./interfaces";

import { updateParameterDescriptions } from "./Description";
import { updateParameterNames } from "./Name";

export function ParameterMetadata(name: string, description: string) {
return (
target: any,
propertyKey: string | symbol,
parameterIndex: number,
) => {
// Update the parameter name metadata
updateParameterNames(
target,
propertyKey,
parameterIndex,
name,
);
// Update the parameter description metadata
updateParameterDescriptions(
target,
propertyKey,
parameterIndex,
description,
);
// Pull the signature's metadata
const parameterMetadata: SignatureMetadataType = (
Reflect.getOwnMetadata(
PARAMETER_METADATA_KEY,
target,
propertyKey,
)
||
[]
);
// Define or overwrite the metadata for this parameter
parameterMetadata[parameterIndex] = {
description,
name,
};
// Update the signature metadata
Reflect.defineMetadata(
PARAMETER_METADATA_KEY,
parameterMetadata,
target,
propertyKey,
);
};
}

The ParameterMetadata decorator updates both names and descriptions as well as signature metadata. As I mentioned earlier, it would be fairly straightforward to update its signature to request an IParameterMetadata object (instead of [string, string]), but I didn't think of that until I started annotating the example so I didn't do that.

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
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import "reflect-metadata";

import {
PARAMETER_DESCRIPTION_KEY,
PARAMETER_METADATA_KEY,
PARAMETER_NAME_KEY,
} from "./constants";

import { Description } from "./Description";
import { Name } from "./Name";
import { ParameterMetadata } from "./ParameterMetadata";

class ArbitraryMetadata {
public nameOnly(
@Name("propertyWithNameOnly")
propertyWithNameOnly: any,
) {
// do nothing
}

public descriptionOnly(
@Description("decorated with Description")
propertyWithDescriptionOnly: any,
) {
// do nothing
}

public usingParameterMetadata(
@ParameterMetadata(
"decoratedWithParameterMetadata",
"decorated with ParameterMetadata",
)
decoratedWithParameterMetadata: any,
) {
// do nothing
}

public chainingDecorators(
@Name("decoratedViaChain")
@Description("decorated with Name and Description")
decoratedViaChain: any,
) {
// do nothing
}
}

// These are not defined on the class
// names
console.log(
"ArbitraryMetadata names:",
Reflect.getMetadata(
PARAMETER_NAME_KEY,
ArbitraryMetadata,
),
);
// descriptions
console.log(
"ArbitraryMetadata descriptions:",
Reflect.getMetadata(
PARAMETER_DESCRIPTION_KEY,
ArbitraryMetadata,
),
);
// signature metadata
console.log(
"metadata from ArbitraryMetadata signatures:",
Reflect.getMetadata(
PARAMETER_METADATA_KEY,
ArbitraryMetadata,
),
);
// They're defined on an instance
const demoArbitraryMetadata = new ArbitraryMetadata();

// This could be created via decorators
// Since it requires more than parameter decorators, it's hardcoded
const METHODS = [
"nameOnly",
"descriptionOnly",
"usingParameterMetadata",
"chainingDecorators",
];

// Loop over each method
for (const method of METHODS) {
// Line break to make things easier to read
console.log("---");
// Log the parameter names
console.log(
`${method} names:`,
Reflect.getMetadata(
PARAMETER_NAME_KEY,
demoArbitraryMetadata,
method,
),
);
// Log the parameter descriptions
console.log(
`${method} descriptions:`,
Reflect.getMetadata(
PARAMETER_DESCRIPTION_KEY,
demoArbitraryMetadata,
method,
),
);
// Log the full signature metadata
console.log(
`${method} signature metadata:`,
Reflect.getMetadata(
PARAMETER_METADATA_KEY,
demoArbitraryMetadata,
method,
),
);
}

Putting everything together, we can use any of the decorators we'd like. We could chain any combination we'd like, but it's important to remember how decorator chaining works; essentially the outermost (first, top, whatever) decorator will overwrite anything set by inner decorators.

$ ts-node arbitrary/main.ts
ArbitraryMetadata names: undefined
ArbitraryMetadata descriptions: undefined
metadata from ArbitraryMetadata signatures: undefined
---
nameOnly names: [ 'propertyWithNameOnly' ]
nameOnly descriptions: undefined
nameOnly signature metadata: [ { description: '', name: 'propertyWithNameOnly' } ]
---
descriptionOnly names: undefined
descriptionOnly descriptions: [ 'decorated with Description' ]
descriptionOnly signature metadata: [ { description: 'decorated with Description', name: '' } ]
---
usingParameterMetadata names: [ 'decoratedWithParameterMetadata' ]
usingParameterMetadata descriptions: [ 'decorated with ParameterMetadata' ]
usingParameterMetadata signature metadata: [ { description: 'decorated with ParameterMetadata', name: 'decoratedWithParameterMetadata' } ]
---
chainingDecorators names: [ 'decoratedViaChain' ]
chainingDecorators descriptions: [ 'decorated with Name and Description' ]
chainingDecorators signature metadata: [ { description: 'decorated with Name and Description', name: 'decoratedViaChain' } ]

Recap

Parameter decorators are great at adding extra information about parameters at runtime. They can't do a whole lot more. Parameter 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.

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