This post is part of a series:
- Part 1: A possible future without Angular Modules (this one)
- Part 2: Higher order and dynamic Components
Since EcmaScript already offers a module system, the purpose of Angular Modules (NgModules) can be puzzling. That's why this topic gets a lot of attention in my Angular workshops. The good news is that with Angular Ivy, NgModules are not strictly required—at least not in the background.
A few things I'd like to explore:
- ? What a future with optional NgModules could look like
- ? How we can start getting ready for that future right now
The example code is available in this GitHub repository.
A special thank you to Minko Gechev from the Angular team for reviewing this piece.
DISCLAIMER: Everything shown here is an experiment—it's a glimpse into one direction Angular could take. It isn't production-ready and it does not represent any official plans from the Angular team. Still, it highlights what Ivy makes possible and points to practical steps we can adopt today to prepare for a world where NgModules are optional.
The backstory
Back when Angular—then called Angular 2—was being designed, the team had no intention of introducing a dedicated module system. While that was a necessity in AngularJS 1.x, the goal for Angular (2+) was to simply use the EcmaScript modules that arrived in 2015.
Thus, NgModules were included in a longer list of AngularJS 1.x concepts meant to be left behind in the next generation of Google's SPA framework. The graphic below, from Igor Minar's and Tobias Bosch's talk at ngEurope 2014 in Paris, makes this clear:

But as Angular's development progressed, it became clear that NgModules bring some real advantages. One is lazy loading. Another is that they give the compiler a context—the compiler has to know exactly which components, directives, and pipes can appear in any given template.
Ivy, in contrast, establishes a compilation context differently—at least behind the scenes. Let's dive into how.
How Ivy defines a compilation context
One of Ivy's strong points is that it generates less complex code. When a component is compiled, Ivy attaches a static ngComponentDef property to it. That property holds a definition object that describes the component to the compiler.
Directives and pipes get similar treatment, with definitions stored in ngDirectiveDef and ngPipeDef properties respectively.
These are accessible at runtime, too:
const def: ComponentDef<AppComponent> = AppComponent['ngComponentDef'];
console.debug('def', def);
Here's what you'd see in the console:

As shown, those properties contain everything Angular needs when executing the component. Notice the directiveDefs and pipeDefs properties—they hold the compilation context we mentioned earlier.
The first points to an array with definitions for directives and components. (Keep in mind that a component definition is really just a specialized directive definition.) It can also point to a factory function that returns such an array.
In the same way, pipeDefs holds pipe definitions.
With these pieces in place, Angular knows exactly which sub-components, directives, and pipes are legal within a component's template.
That raises the question of how these properties get filled. To avoid breaking changes, the Ivy compiler looks up the relevant entries in the component's module and any modules imported there. Then it collects the discovered components, directives, and pipes into directiveDefs or pipeDefs.
But if we wanted to skip modules altogether with Ivy, we could populate these two properties directly. Let's look at how that might work.
Setting the compilation context without NgModules
On a technical level, we can add a compilation context straight into directiveDefs and pipeDefs. At the moment, however, these are not part of Angular's public API.
There's a good reason for that: the Angular team is first making sure Ivy works perfectly with everything currently in place. Only after that will they gradually roll out new Ivy-based features.
When that happens, the Component decorator might pick up additional properties for exactly this purpose.
As the next section demonstrates, we can get a sneak peek at that potential future today.
Supplying the compilation context directly
Because there's no public API to set the compilation context directly, we'll tap into some private internals here. Those are subject to change, so what follows is not something for production code.
Even so, this experiment gives us an early look at what Angular code could look like down the road. It also leads to a key takeaway on how to prepare for that future.
For the demo, I'm using a minimal example: a tabbed-pane that shows one tab at a time:

The AppComponent brings them together:
<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>
Inside TabbedPaneComponent, Angular's *ngIf controls tab visibility, and *ngFor is used to render the links.
To supply the compilation context directly, we first need to grab the definition objects for these components. To make that easier, I've put together a helper:
import { Type } from "@angular/core";
import { ɵComponentDef as ComponentDef } from '@angular/core';
[...]
export function getComponentDef<T>(t: Type<T>): ComponentDef<T> {
if (t['ngComponentDef']) {
return t['ngComponentDef'] as ComponentDef<T>;
}
throw new Error('No Angular definition found for ' + t.name);
}
You may notice the ComponentDef type has a ɵ prefix—a signal that it's still within Angular's private API.
I've also created similar helpers for DirectiveDef and PipeDef:
export function getDirectiveDef<T>(t: Type<T>): DirectiveDef<T> {
if (t['ngDirectiveDef']) {
return t['ngDirectiveDef'] as DirectiveDef<T>;
}
// A Component(Def) is also a Directive(Def)
if (t['ngComponentDef']) {
return t['ngComponentDef'] as ComponentDef<T>;
}
throw new Error('No Angular definition found for ' + t.name);
}
export function getPipeDef<T>(t: Type<T>): PipeDef<T> {
if (t['ngPipeDef']) {
return t['ngPipeDef'] as PipeDef<T>;
}
throw new Error('No Angular definition found for ' + t.name);
}
There are also utility functions for pulling all definition objects from given arrays of directives and 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));
}
The first of these also accounts for components, given that a component definition is a type of directive definition. Technically, ComponentDef even extends DirectiveDef.
With these helpers in place, we can assign the compilation context to our components like so:
@Component({ [...] })
export class AppComponent {
title = 'demo';
}
// Adding compilation context
const def = getComponentDef(AppComponent);
def.directiveDefs = [
getDirectiveDef(TabComponent),
getDirectiveDef(TabbedPaneComponent)
];
For simplicity, I'm overwriting the directiveDefs property outright, which also wipes out anything the compiler inserted after inspecting any existing modules.
Having to attach the same definitions over and over gets tedious quickly. A tidy alternative is to group components that belong together in a central array:
export const TABBEND_PANE_COMPONENTS = [
TabbedPaneComponent,
TabComponent
];
To bring that array in, we can rely on our own getDirectiveDef helper:
def.directiveDefs = [ ...getDirectiveDefs(TABBEND_PANE_COMPONENTS) ];
Likewise, I've created a file that exports all the directives from @angular/common that I need:
export const COMMON_DIRECTIVES = [
NgIf,
NgForOf,
// etc.
];
And that's used inside the TabbedPaneComponent:
@Component({ [...] })
export class TabbedPaneComponent implements AfterContentInit {
[...]
}
const def = getDef(TabbedPaneComponent);
def.directiveDefs = [
...getDefs(COMMON_DIRECTIVES)
];
With only two components here, the payoff isn't huge, but in larger setups with plenty of shared pieces, this type of array becomes quite handy.
Patching a component after the fact is a bit crude, so the next section improves on the approach.
Using a decorator to provide the compilation context
To make this feel more familiar to Angular developers, I wrote a small 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 || [])
];
}
}
More precisely, it's a factory for a decorator. You give it a config object with the compilation context, and it returns a decorator that adds that context to the component's definition.
Now we can apply it to our components:
@Component({ [...] })
@ComponentDeps({
directives: [
...TABBEND_PANE_COMPONENTS
]
})
export class AppComponent {
title = 'demo';
}
This looks close to the vision where the component decorator takes the compilation context directly. As it happens, Minko Gechev has also built a prototype that plays with this idea, using a deps property on the Component decorator for the same effect. Again, this is all exploratory and not an endorsement from the Angular team.
So the natural question becomes: what can we take away from this? The next section addresses just that.
Getting ready for a future without (or with optional) NgModules
Throughout this article, we've seen that a compilation context is necessary. Without something like NgModules, we could assign it directly to components. That's already feasible with private APIs, and the related properties may one day be exposed publicly.
We also saw the value of grouping components, directives, and pipes that are logically related. A simple array does the trick:
export const TABBEND_PANE_COMPONENTS = [
TabbedPaneComponent,
TabComponent
];
Interestingly, that array resembles the export portion of an NgModule—except it's pure EcmaScript, which keeps things simpler and easier to follow.
Even without NgModules, though, we still need a way to bundle related parts of our code. We also need information hiding—distinguishing public from private parts of an API. Here again, EcmaScript offers a clean solution: barrels.
In my case study, the tabbed-pane folder contains an index.ts like this:
export * from './tab.component';
export * from './tabbed-pane.component';
// array with components
export * from './components';
The app.component.ts pulls from that barrel:
import { TABBEND_PANE_COMPONENTS } from './tabbed-pane';
We can take this even further with a monorepo containing multiple libraries. Each library has a barrel that defines its public API. The Angular CLI makes it trivial to scaffold a library (ng generate lib my-lib), which also boosts reusability.
If you use Nrwl's Nx to structure an Angular CLI monorepo, you can also enforce access boundaries between libraries. Nx includes lint rules that prevent accidental imports from private parts of an API, i.e., bypassing the barrel files.
With all this in mind, I have clear advice on how to prep for a possible future where Angular modules are optional.
Bottom line
As we've seen, it's possible now to get ready for a future without (or with optional) NgModules. All it takes is:
- Split your application into libraries and use barrels to expose their public APIs.
- When NgModules become optional, swap them out for their
exportarrays.
And here's the real kicker: whether or not this future ever comes, this guidance stands on its own. Breaking an application into small libraries, each with an explicit public surface and hidden internals, leads to a more solid — and more sustainable — architecture.
