Back in 2016 Angular was the first major web framework to launch with TypeScript as the primary authoring language, using ES6 classes, observables and decorators as its core building blocks.
Angular was taking some very big risks here. RxJS 5 was still in beta. To this day there is still no officially accepted proposal for JavaScript observables or decorators. The first stable release of "Angular 2" (now just "Angular") was a bit of a disaster. It wasn't until Angular 4 launched with its new Ahead-of-Time (AOT) compiler that new-Angular really hit its stride.
@Component({
template: `
<div>Hello {{name}}!</div>
`,
styles: [`h1 { font-family: Lato; }`]
})
export class HelloComponent {
@Input() name: string
}
Now when we write an Angular component the decorator is magicked away during the AoT compilation process, where it is transformed and in-lined into static properties of the component class.
export class HelloComponent {
static ɵcmp = {
declaredInputs: Object { name: "name" },
inputs: Object { name: "name" },
selectors: [["hello"]],
styles: ["h1[_ngcontent-%COMP%] { font-family: Lato; }"],
template: function HelloComponent_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelementStart(0, "h1");
i0.ɵɵtext(1);
i0.ɵɵelementEnd();
} if (rf & 2) {
i0.ɵɵadvance(1);
i0.ɵɵtextInterpolate1("Hello ", ctx.name, "!");
}
},
...etc
}
static ɵfac = function HelloComponent_Factory() {
return new HelloComponent()
}
}
If you serve an Angular application and look at the compiled output you will see code like the example above. The Angular decorators aren't anywhere to be seen. However, that is not always the case...
Ahead-of-Time vs Just-in-Time compilation
Angular has two compilers, Ahead-of-Time (AOT) and Just-in-Time (JIT). Most of the time you don't notice the difference until you do.
Ahead-of-Time
This is the default compiler when serving and building Angular apps. Angular decorators are compiled into static properties during the build process and don't exist at runtime.
Just-in-Time
This is the compiler used when AOT compilation is disabled or not available. Angular decorators are evaluated at runtime, just before the application runs. This requires the Angular compiler to be shipped to the browser.
Unit Tests
When we run unit tests, Angular uses a combination of AOT and JIT compilation modes. If the code is already AOT compiled (eg. a library), it will use the compiled code. If the code is not compiled yet (eg. a component under test), JIT compilation is used.
Can you guess when the compilation happens?
@Component({ template: "" })
class UITest {}
describe("UITest", () => {
it("should compile", () => {
TestBed.configureTestingModule({
declarations: [UITest]
})
const fixture = TestBed.createComponent(UITest)
expect(fixture.componentInstance).toBeInstanceOf(UITest)
})
})
That's right, the compilation happens when we call createComponent
. The first call to TestBed.createComponent
or TestBed.inject
bootstraps the test environment and triggers JIT compilation.
Why Meta-Programming In Angular Sucks
Now that we have the backstory, here are the reasons why meta-programming in Angular sucks.
No Officially Supported Meta-Programming API
Angular, which uses decorators, does not let us add our own custom decorators! We are forced to deal with the idiosyncrasies of AOT and JIT compilation. There is no official support from Angular.
For meta-programming in Angular to be useful, we need to be able to do three things:
- Obtain a reference to a decorated component's instance and injector.
- Run initialization logic.
- Run logic during lifecycle hooks.
Object Identity
Let's say we want to write a decorator that extends an Angular component.
function Store() {
return function (target) {
return class WithStore extends target {
constructor(...args) {
super(...args)
console.log("extended!", inject(INJECTOR))
}
}
}
}
What happens when we try to inject the decorated class?
@Store()
@Component()
export class UICounter {
constructor(injector: Injector) {
setTimeout(() => {
console.log(injector.get(UICounter))
})
}
}
If you run this code in a unit test, it logs the component instance. If you run this code in the actual app, it throws:
NG0201: No provider for WithStore found!
Wait, what? To understand this, remember that Store
is returning a new class called WithStore
, not UICounter
. In AOT compilation, Angular statically constructs the dependency injection container. It doesn't "see" WithStore
since it hasn't executed yet. So the container is created with reference to UICounter
. When we try to inject WithStore
, no provider is found.
Why does this work in the unit test? In JIT mode the dependency injection container isn't created until the component is compiled, which is after the Store
decorator is evaluated.
Order of Execution
Which decorator executes first?
@Store()
@Component()
export class UICounter {}
TypeScript experimental decorators are always called from bottom-to-top, so Component
is called before Store
. If we run this in an application we see this is true.
What about now?
@Component()
@Store()
export class UICounter {}
Running this code inside a unit test shows that Store
is called first as we expect. In the actual app however, Component
is executed during the build process, before Store
has a chance to execute. This is a problem if Store
depends on metadata set by Component
. To make this work in both JIT and AOT, Store
should be deferred until we know Component
is executed.
These surprising behaviours make meta-programming in Angular almost impossible, but if you are willing to risk it there is a way.
Rules for Decorators
Here are five simple rules to follow for writing good decorators.
1. Never extend the base class
Extending a component is the easiest way to add some logic when it is created. As of Angular 14 we can also use inject
to save a reference to the injector. However, since decorators are not statically analysable we run into the Object Identity problem.
2. Never replace a decorated class property with an incompatible type signature
Class field, accessor and method decorators should only replace the decorated property with a value that satisfies the original type signature.
@Store()
class UITodos {
http = inject(HttpClient)
@Action() loadTodos(userId: string): Observable<Todo[]> {
return this.http.get(endpoint, { params: { userId }})
}
}
Here the Action
decorator taps the returned observable, returning a new observable that mirrors the original return type. Since TypeScript can't detect decorator type incompatibilities it is up to the author to ensure runtime types are compatible.
@Store()
class UITodos {
todos: Todo[] = []
@Select() get remaining(): Todo[] {
return this.todos.filter(todo => !todo.completed)
}
}
Here's another example with an accessor field. The Select
decorator memoizes the getter function without changing its semantics.
Don't turn class fields into class accessors. Even if the type signature looks the same, the runtime semantics are completely different.
3. Don't inherit decorators from base classes
It's not worth the trouble. Lets avoid using class inheritance entirely. If you do explore this area you might run into obscure problems with code instrumentation in unit tests.
4. Don't extend the public API of a class.
Similar to Rule 2, don't use field decorators to add new properties to a class. Field decorators should only record metadata for a class decorator to process.
@Store()
@Component()
export class UICounter {
count = 0
@Action() increment() {
this.count++
}
}
The Action
decorator does nothing but record the field name as metadata. When Store
executes it reads the metadata and wraps the increment
method by mutating the class prototype. It also attaches Angular lifecycle hooks.
From the consumer's perspective the class API hasn't changed.
5. Don't use decorators for everything
Unless you have a really good reason to, writing your own decorators should be avoided altogether.
How to decorate an Angular component
Currently this is only possible by accessing private Angular APIs. Use at your own risk.
@Component()
export class UICounter {
@Input() count = 0
}
Rule 1 says we cannot use decorators to extend a class. So how do we obtain the component instance and injector?
const injector = Symbol("injector")
function Store() {
return function(target) {
const factory = target["ɵfac"]
Object.defineProperty(target, "ɵfac", {
value: function (...args) {
const cmp = factory(...args)
Reflect.defineMetadata(injector, inject(INJECTOR), cmp)
return cmp
}
})
}
}
Thankfully, Angular does not invoke component class constructors directly. Instead, the Angular compiler creates a factory function that instantiates the component for us. This factory is also responsible for dependency injection in the constructor. By wrapping this factory we can then intercept the constructor call, obtain the component instance, and save a reference to the injector for later use.
@Store()
@Component()
export class UICounter {
@Input() count = 0
@Action() increment() {
this.count++
}
}
So we managed to solve the Object Identity, but we still have a Order of Execution problem when we try to run this code in unit tests which use JIT instead of AOT compilation.
@Component()
@Store() // target["ɵfac"] is undefined
export class UICounter {}
The end user shouldn't have to worry about the order of execution. In AOT everything works as expected, so let's focus on making JIT work.
const injector = Symbol("injector")
const decorators = new Map()
function wrapFactory(target) {
const factory = target["ɵfac"]
Object.defineProperty(target, "ɵfac", {
value: function (...args) {
const cmp = factory(...args)
Reflect.defineMetadata(injector, inject(INJECTOR), cmp)
return cmp
}
})
}
function Store() {
return function(target) {
const factory = target["ɵfac"]
if (factory) {
wrapFactory(target)
} else {
decorators.add(target, wrapFactory)
}
}
}
function processDecorators() {
for (const [target, decorate] of decorators) {
decorate(target)
}
decorators.clear()
}
If we detect that there's an existing factory, then we wrap the factory immediately. Otherwise we cache the operation until we call processDecorators
to finish decorating the component. For unit tests all we need to do is call processDecorators
when the test environment is created.
@NgModule()
export class InitStoreTestingModule {
constructor() {
processDecorators()
}
}
function initStoreTestingEnvironment() {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [InitStoreTestingModule]
})
})
}
Then just call the function inside the main test setup file
// test.ts
getTestBed().initTestingModule()
initStoreTestingEnvironment() // call before any tests are loaded
Since modules are loaded eagerly, and we register it as the first module, it will run before any component is created, but after all decorators have been evaluated. The decorator now works in both JIT and AOT modes.
Working Example
Good decorators are difficult to write, but easy to use. To see this in action check out how Angular State Library uses decorators to eliminate the boilerplate of state management.
Thanks for reading!
Top comments (0)