Building a Custom Component Decorator in Angular
Angular leverages two JavaScript capabilities that are currently undergoing standardization — decorators and the metadata reflection API — to enable declarative component definitions. Neither feature is natively available in browsers yet, but both are advancing toward widespread support. In the interim, Angular relies on the TypeScript compiler to handle decorators, while the reflect-metadata npm package provides a shim for the metadata reflection API. A wealth of resources online delve into the intricacies of decorators and the metadata API. This article focuses on how Angular puts them to work.
When defining a new component in Angular, we typically reach for the @Component decorator, as shown below:
@Component({
selector: 'my-app',
template: '<span>I am a component</span>',
})
export class AppComponent {
name = 'Angular';
}
According to the specification, a decorator is an expression that evaluates to a function receiving the target, name, and decorator descriptor as parameters. What does it mean for a decorator to "evaluate to a function"? In practice, it means you can apply a decorator directly:
@isTestable
class MyClass { }
function isTestable(target) {
target.isTestable = true;
}
Alternatively, you can employ a wrapper approach — often termed a "decorator factory" — which returns a decorator function:
@isTestable(true)
class MyClass { }
function isTestable(value) {
return function decorator(target) {
target.isTestable = value;
}
}
All Angular decorators adopt the second pattern, using a wrapper function. The fundamental role of most Angular decorators is to attach metadata to a class. Later, the compiler consumes this metadata to generate the necessary factories.
To grasp the concept more concretely, let’s craft a custom decorator for defining components. First, we need to understand the full set of properties a component decorator can accept. These are cataloged here:
export const defaultComponentProps = {
selector: undefined,
inputs: undefined,
outputs: undefined,
host: undefined,
exportAs: undefined,
moduleId: undefined,
providers: undefined,
viewProviders: undefined,
changeDetection: ChangeDetectionStrategy.Default,
queries: undefined,
templateUrl: undefined,
template: undefined,
styleUrls: undefined,
styles: undefined,
animations: undefined,
encapsulation: undefined,
interpolation: undefined,
entryComponents: undefined
};
As noted earlier, Angular takes the wrapper function route for its decorators. This wrapper accepts component properties and blends them with predefined defaults. Let’s implement that logic:
export function CustomComponentDecorator(_props) {
_props = Object.assign({}, defaultProps, _props);
return function (cls) { }
}
I also mentioned that a decorator’s sole purpose in Angular is to bind metadata to a class. This metadata is essentially the outcome of merging defaults with the user-specified properties. Angular assumes the existence of a global Reflect object exposing methods to define and retrieve metadata. Armed with this, we can refine our implementation:
const Reflect = global['Reflect'];
export function CustomComponentDecorator(_props) {
_props = Object.assign({}, defaultProps, _props);
return function (cls) {
Reflect.defineMetadata('annotations', [_props], cls);
}
}
This captures the very core of the @Component decorator. At this stage, we could swap in our custom decorator in place of the framework’s built-in one:
@CustomComponentDecorator({
selector: 'my-app',
template: '<span>I am a component</span>',
})
export class AppComponent {
name = 'Angular';
}
But running the application would trigger an error:
Unexpected value ‘AppComponent’ declared by the module ‘AppModule’. Please add a @Pipe/@Directive/@Component annotation.
The issue arises because Angular performs a runtime verification on each metadata instance to ensure it was created via the proper decorators. Since we used our own decorator, that validation fails. Fortunately, the check is straightforward:
function isDirectiveMetadata(type: any): type is Directive {
return type instanceof Directive;
}
It simply confirms whether a metadata instance is derived from the DecoratorFactory. This function is private and not exported for import. Yet, using JavaScript’s prototypal nature, we can extract it from any existing decorated metadata:
const c = class c {};
Component({})(c);
const DecoratorFactory = Object.getPrototypeOf(Reflect.getOwnMetadata('annotations', c)[0]);
Once we have access to DecoratorFactory, the fix involves establishing the correct prototype chain in our custom decorator function:
export function CustomComponentDecorator(_props) {
_props = Object.assign({}, defaultProps, _props);
Object.setPrototypeOf(_props, DecoratorFactory);
return function (cls) {
Reflect.defineMetadata('annotations', [_props], cls);
}
}
For better performance, we can rewrite the same logic using Object.create instead of:
export function CustomComponentDecorator(_props) {
let props = Object.create(DecoratorFactory);
props = Object.assign(props, defaultComponentProps, _props);
return function (cls) {
Reflect.defineMetadata('annotations', [props], cls);
}
}
And just like that, we’ve built a fully functional alternative to the standard @Component decorator.
