Architecture with Angular Ivy - Part 1: A possible future without NgModules?

Manfred Steyer - Aug 16 '19 - - Dev Community

In this post I want to show:

  • 🔭 How a possible future without (with optional) NgModules could look like
  • 🐹 How we can prepare for this future already today

The source code used for the examples can be found here.

DISCLAIMER: The examples here are an experiment showing a possible direction Angular might be heading to. Hence, it's not production ready and it does not reflect the Angular teams's official vision. Nethertheless, it shows what's possible with Ivy and it leads to a conclusion about how we can prepare already today for a future without NgModules.

A little history

When Angular -- back than called Angular 2 -- was envisioned, the team didn't plan to implement an own module system. While this was necessary in the times of AngularJS 1.x, the idea for Angular (2+) was to just leverage EcmaScript modules introduced in 2015.

And so, Angular Modules have been among several AngularJS 1.x aspects that should have been skipped in the upcoming version of Google's SPA flagship. The following graphic, taken out of Igor Minar's and Tobias Bosch's presentation at ngEurope 2014 in Paris emphasizes this:

AngularJS 1.0 aspects skipped for Angular

However, during the development of Angular it became obvious, that Angular Modules can come in handy for several reasons. One reason was lazy loading; another reason was the need to provide context for the Angular compiler. This is because the compiler needs to know which components, directives, and pipes are available within a template.

On the contrary, Ivy has a different strategy to define the compilation context -- at least under the covers. The next section gives some details about this.

Compilation Context for Ivy

One of the benefits of Ivy is that it produces simpler code. During compilation, it just adds a static ɵcmp property to components. This property contains a definition object describing the component for the compiler.

Similarly, the compiler adds also such a definition to directives and pipes. They are called ɵdir and ɵpipe.

You can even access it at runtime:

const def: ComponentDef<AppComponent> = AppComponent['ɵcmp'];
console.debug('def', def);
Enter fullscreen mode Exit fullscreen mode

The respective console output would look like this:

console output of ComponentDef

As you see here, these properties contain everything Angular needs to know when executing the component. Perhaps you've noticed the directiveDefs and pipeDefs properties. They contain the compilation context for the template we've talked about above.

The first one points to an array of definitions for directives and components. Saying this, a component definition is just a special kind of a directive definition. Alternatively, directiveDefs can point to a factory function returning such an array.

The pipeDefs property does the same for pipe definitions.

This way, Angular knows which (sub-)components, directives, and pipes are available in a component's template.

Now, you might wonder how those properties are filled with the right values. In order to prevent breaking changes, the Ivy compiler just looks up the necessary entries in the component's module as well as in all other modules imported there. After this, it adds the found components, directives, and pipes to directiveDefs or pipeDefs.

However, if we wanted to use Ivy without modules, we could directly populate these two property. The next section provides some information about this idea.

Compilation Context without NgModules

Technically, it's possible to directly add the compilation context to directiveDefs and pipeDefs. Unfortunately, these properties are not part of Angular's public API currently.

This is for a reason: First of all, the Angular team concentrates on making sure Ivy is completely backwards compatible. Only than, they will introduce new features based upon Ivy step by step.

When that happens, the Component decorator might get some additional properties for this reason.

As the next section shows, we can already have a short look into this possible future today.

Calming the Angular-Compiler

While this all will work at runtime, it might cause some troubles during compilation due some of the Angular compiler's checks. For instance, it checks whether the used components and pipes are available by looking into NgModules. Of course, these checks will not work in our case as the goal was to work without them.

To calm the compiler, we should turn of fullTemplateTypeCheck in tsconfig.json:

"angularCompilerOptions": {
    "fullTemplateTypeCheck": true,
    "strictInjectionParameters": true,
}
Enter fullscreen mode Exit fullscreen mode

Also, we should import the NO_ERRORS_SCHEMA into out AppModule:

@NgModule({
    imports: [
        BrowserModule
    ],
    declarations: [
        AppComponent
    ],
    schemas: [
        NO_ERRORS_SCHEMA
    ],
    providers: [],
    bootstrap: [
        AppComponent
    ]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

In this case study, the AppModule is the only module used. Technically, we could even get rid of this module.
Instead, we could just bootstrap the AppComponent using Ivy's ɵrenderComponent function which is also part of the private API. However, this would make things more complicated as renderComponent is currently not supporting automatic change detection out of the box.

Directly Providing the Compilation Context

As directly providing the compilation context is not possible with public APIs today, this section directly leverages the available private ones. Of course, they can change and hence the techniques outlined here are not feasible for production code.

Nethertheless, this experiment shows how modern Angular code might look like in the future. It also leads to a conclusion that tells us, how we can prepare for this future.

To demonstrate how to directly provide the compilation context, I'm using a simple example here. It just contains a tabbed-pane which displays one of several tabs at a time:

Tabbed Pane example

The example's AppComponent uses them:

<tabbed-pane>
  <tab title="Tab 1">
    Lorem ipsum ...
  </tab>
  <tab title="Tab 2">
    Lorem ipsum ...
  </tab>
  <tab title="Tab 3">
    Lorem ipsum ...
  </tab>
</tabbed-pane>
Enter fullscreen mode Exit fullscreen mode

For showing and hiding the tabs, the TabbedPaneComponent uses Angular's *ngIf directive and for displaying the link it uses *ngFor.

If we want to provide the compilation context directly to these components, we need to get hold of the respective definition objects. For this, I've created a helper function:

import { Type } from "@angular/core";
import { ɵComponentDef as ComponentDef } from '@angular/core';

[...]

export function getComponentDef<T>(t: Type<T>): ComponentDef<T> {
  if (t['ɵcmp']) {
    return t['ɵcmp'] as ComponentDef<T>;
  }

  throw new Error('No Angular definition found for ' + t.name);
}
Enter fullscreen mode Exit fullscreen mode

As you might have noticed, the ComponentDef class is prefixed with the ɵ sign. This denotes that the class is still part of Angular's private API.

I've written similar helper functions for getting DirectiveDefs and PipeDefs:

export function getDirectiveDef<T>(t: Type<T>): DirectiveDef<T> {

    if (t['ɵdir']) {
      return t['ɵdir'] as DirectiveDef<T>;
    }

    // A Component(Def) is also a Directive(Def)
    if (t['ɵcmp']) {
      return t['ɵcmp'] as ComponentDef<T>;
    }

    throw new Error('No Angular definition found for ' + t.name);
}

export function getPipeDef<T>(t: Type<T>): PipeDef<T> {

  if (t['ɵpipe']) {
    return t['ɵpipe'] as PipeDef<T>;
  }

  throw new Error('No Angular definition found for ' + t.name);
}
Enter fullscreen mode Exit fullscreen mode

Similarly, I've written helper functions for getting all the definition objects from an array of directives and an array of pipes:

export function getDirectiveDefs(types: Type<any>[]): DirectiveDef<any>[] {
  return types.map(t => getDirectiveDef(t));
}

export function getPipeDefs(types: Type<any>[]): PipeDef<any>[] {
  return types.map(t => getPipeDef(t));
}
Enter fullscreen mode Exit fullscreen mode

Please note that the former one is also respecting component definitions, as a component definition is a special kind of a directive definition. Technically, ComponentDef even inherits DirectiveDef.

Using these helpers, we can easily add the compilation context to our components:

@Component({ [...] })
export class AppComponent {
  title = 'demo';
}

// Adding compilation context
const def = getComponentDef(AppComponent);

def.directiveDefs = [
  getDirectiveDef(TabComponent), 
  getDirectiveDef(TabbedPaneComponent)
];
Enter fullscreen mode Exit fullscreen mode

For the sake of simplicity, I'm overwriting the directiveDefs property here. This means that values the compiler placed there after traversing possibly existing modules are overwritten too.

Of course, adding the same definition objects time and again to different consuming objects is annoying. However, we could group components belonging together using a central array:

export const TABBED_PANE_COMPONENTS = [
    TabbedPaneComponent,
    TabComponent
];
Enter fullscreen mode Exit fullscreen mode

To import this array, we can use our getDirectiveDef helper function:

def.directiveDefs = [
  ...getDirectiveDefs(TABBED_PANE_COMPONENTS)
];
Enter fullscreen mode Exit fullscreen mode

Similarly to this, I've written a file exporting all the directives I need from @angular/common:

export const COMMON_DIRECTIVES = [
    NgIf,
    NgForOf,
    // etc.
];
Enter fullscreen mode Exit fullscreen mode

This one is used for the TabbedPaneComponent:

@Component({ [...] })
export class TabbedPaneComponent implements AfterContentInit {
    [...]
}

const def = getDef(TabbedPaneComponent);

def.directiveDefs = [
     ...getDefs(COMMON_DIRECTIVES)
];
Enter fullscreen mode Exit fullscreen mode

Of course, as we have just two components, this is not a big win here but in bigger scenarios with lots of components using such an array comes quite in handy.

As patching a component after defining it is somewhat brutal, the next section shows a better way.

Providing the compilation context with a decorator

To add the compilation context to components in a way we are more used to as Angular developers, I've written a simple decorator:

export interface ComponentDepsConfig {
  directives?: Type<any>[];
  pipes?: Type<any>[];
}

export function ComponentDeps(config: ComponentDepsConfig) {
  return (component) => {

    const def = getComponentDef(component);

    def.directiveDefs = [
      ...getDirectiveDefs(config.directives || [])
    ];

    def.pipeDefs = [
      ...getPipeDefs(config.pipes || [])
    ];

  }
}
Enter fullscreen mode Exit fullscreen mode

To be more precise, this is a factory for a decorator. It takes a config object containing the compilation context and returns a decorator for a component. This decorator adds the context to the component's definition.

Now, let's apply this decorator to our components:

@Component({ [...] })
@ComponentDeps({
  directives: [ 
    ...TABBED_PANE_COMPONENTS
  ]
})
export class AppComponent {
  title = 'demo';
}
Enter fullscreen mode Exit fullscreen mode

This looks a lot like the envisioned possible future where the component decorator directly takes the compilation context. Also, Angular's Minko Gechev created a prototype of Angular which uses this idea. It's Component decorator come with a deps property that serves the same purpose. One more time, this is all is about showing what's possible and it doesn't reflect the Angular teams's official vision.

However, now the question is what can we learn from this case study. The next section provides an answer.

Preparing for a possible future without (with optional) NgModules

In this article, we've seen that we need a compilation context. If we don't have something like NgModules we could assign it directly to our components. This is already possible by using internal APIs and the respective properties might eventually be exposed a part of Angular's public API.

We've also seen that even in this case, it's in handy to group components, directives, or pipes belonging together. For this reason we've used a simple array:

export const TABBED_PANE_COMPONENTS = [
    TabbedPaneComponent,
    TabComponent
];
Enter fullscreen mode Exit fullscreen mode

Interestingly, this array look like the export part of an NgModule. However, this is pure EcmaScript which makes its simpler and easier to understand.

Also, it's obvious that even in a future without NgModules we need a way to group parts of our code. We will also need some way of information hiding which means we have to define public and private parts of our APIs. Fortunately, this can also be accomplished with pure EcmaScript by using barrels.

For instance, in my case study, the tabbed-pane folder has the following index.ts:

export * from './tab.component';
export * from './tabbed-pane.component';

// array with components
export * from './components'; 
Enter fullscreen mode Exit fullscreen mode

The app.component.ts imports this barrel:

import { TABBED_PANE_COMPONENTS } from './tabbed-pane';
Enter fullscreen mode Exit fullscreen mode

We can even go one step further and use a monorepo with different libraries. Each library gets a barrel which defines the public API. Thanks to the Angular CLI creating libraries to group your code is very easy. It's just a matter of one command (ng generate lib my-lib) and it also makes your code more reusable.

Furthermore, when using Nrwl's Nx for creating an Angular CLI based monorepo, you can also define access restrictions between your libraries. Also, it contains linting rules preventing that someone uses private parts of your APIs by bypassing their barrels.

This all leads to my advice for preparing for this possible future without (with optional) Angular modules.

Conclusion

As you have seen here, we can already prepare today for a possible future without (with optional) NgModules. It's as easy as that:

  1. Cut your application into libraries and use barrels to define their public APIs.
  2. When NgModules become optional, replace them with an their export arrays.

Finally, let me tell you the best: Regardless, if this possible future will happen or not, this advice makes a lot of sense anyway, because cutting your application into tiny libraries with a public and a private part leads to a more robust architecture.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .