DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 967,911 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Michael Muscat
Michael Muscat

Posted on

Rules for Decorators: Why meta-programming in Angular sucks, and what you can do about it

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
}
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  })
})
Enter fullscreen mode Exit fullscreen mode

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:

  1. Obtain a reference to a decorated component's instance and injector.
  2. Run initialization logic.
  3. 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))
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens when we try to inject the decorated class?

@Store()
@Component()
export class UICounter {
  constructor(injector: Injector) {
    setTimeout(() => {
      console.log(injector.get(UICounter))
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 }})
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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++
  }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
      }
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

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++
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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]
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

Then just call the function inside the main test setup file

// test.ts

getTestBed().initTestingModule()
initStoreTestingEnvironment() // call before any tests are loaded
Enter fullscreen mode Exit fullscreen mode

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)

Classic DEV Post from 2020:

js visualized

πŸš€βš™οΈ JavaScript Visualized: the JavaScript Engine

As JavaScript devs, we usually don't have to deal with compilers ourselves. However, it's definitely good to know the basics of the JavaScript engine and see how it handles our human-friendly JS code, and turns it into something machines understand! πŸ₯³

Happy coding!