DEV Community

Cover image for Tree-shakable dependencies in Angular projects
Lars Gyrup Brink Nielsen for This is Angular

Posted on • Updated on

Tree-shakable dependencies in Angular projects

Cover photo by Paul Green on Unsplash.

Original publication date: 2019-01-22.

Tree-shakable dependencies are easier to reason about and compile to smaller bundles.

Angular modules (NgModules) used to be the primary way to provide application-wide dependencies such as constants, configurations, functions, and class-based services. Since Angular version 6, we can create tree-shakable dependencies and even leave out Angular modules.

Angular module providers create hard dependencies

When we use the providers option of the NgModule decorator factory to provide dependencies, the import statements at the top of the Angular module file reference the dependency files.

This means that all the services provided in an Angular module become part of the bundle, even the ones that aren’t used by declarables or other dependencies. Let’s call these hard dependencies since they aren’t tree-shakable by our build process.

Instead, we can invert the dependencies by letting the dependency files refer to the Angular module files. This means that even though an application imports the Angular module, it does not refer to a dependency until it uses the dependency in, for example, a component.

Providing singleton services

A lot of class-based services are what are known as application-wide singleton services—or simply singleton services, since we rarely use them at the platform injector level.

Pre-Angular 6 singleton service providers

In Angular versions 2 through 5, we had to add singleton services to the providers option of an NgModule. We then had to take care that only eagerly loaded Angular modules imported the providing Angular module— by convention this was the CoreModule of our application.

// pre-six-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable()
export class PreSixSingletonService {
  constructor(private http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode
// pre-six.module.ts
import { NgModule } from '@angular/core';

import { PreSixSingletonService } from './pre-six-singleton.service';

@NgModule({
  providers: [PreSixSingletonService],
})
export class PreSixModule {}
Enter fullscreen mode Exit fullscreen mode
// core.module.ts
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';

import { PreSixModule } from './pre-six.module.ts';

@NgModule({
  imports: [HttpClientModule, PreSixModule],
})
export class CoreModule {}
Enter fullscreen mode Exit fullscreen mode

Pre-Angular 6 singleton service.

If we imported the providing Angular module in a lazy-loaded feature module, we would get a different instance of the service.

Providing services in mixed Angular modules

When providing a service in an Angular module with declarables, we should use the forRoot pattern to indicate that it’s a mixed Angular module— that it provides both declarables and dependencies.

This is important, since importing an Angular module with a dependency provider in a lazy-loaded Angular module will create an instance of the service for that module injector. This happens even if an instance has already been created in the root module injector.

// pre-six-mixed.module.ts
import { ModuleWithProviders, NgModule } from '@angular/core';

import { MyComponent } from './my.component';
import { PreSixSingletonService } from './pre-six-singleton.service';

@NgModule({
  declarations: [MyComponent],
  exports: [MyComponent],
})
export class PreSixMixedModule {
  static forRoot(): ModuleWithProviders {
    return {
      ngModule: PreSixMixedModule,
      providers: [PreSixSingletonService],
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

The forRoot pattern for singleton services.

The static forRoot method is intended for our CoreModule which becomes part of the root module injector.

Tree-shakable singleton service providers

Fortunately, Angular version 6 added the providedIn option to the Injectable decorator factory. This is a simpler approach for declaring application-wide singleton services.

// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class ModernSingletonService {
  constructor(private http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode

Modern singleton service.

A singleton service is created the first time any component that depends on it is constructed.


It’s considered best practice to always decorate a class-based service with Injectable. It configures Angular to inject dependencies through the service constructor.

Prior to Angular version 6, if our service had no dependencies, the Injectable decorator was technically unnecessary. Still, it was considered best practice to add it so that we would not forget to do so if we added dependencies at a later time.

Now that we have the providedIn option, we have another reason to always add the Injectable decorator to our singleton services.

An exception to this rule of thumb is if we create a service that’s always intended to be constructed by a factory provider (using the useFactory option) . If this is the case, we should not instruct Angular to inject dependencies into its constructor.


The providedIn: 'root' option will provide the singleton service in the root module injector. This is the injector created for the bootstrapped Angular module — by convention the AppModule. In fact, this injector is used for all eagerly loaded Angular modules.

Alternatively, we can refer the providedIn option to an Angular module which is similar to what we used to do with the forRoot pattern for mixed Angular modules, but with a few exceptions.

// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

import { ModernMixedModule } from './modern-mixed.module';

@Injectable({
  providedIn: ModernMixedModule,
})
export class ModernSingletonService {
  constructor(private http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode
// modern-mixed.module.ts
import { NgModule } from '@angular/core';

import { MyComponent } from './my.component';

@NgModule({
  declarations: [MyComponent],
  exports: [MyComponent],
})
export class ModernMixedModule {}
Enter fullscreen mode Exit fullscreen mode

Modern forRoot alternative for singleton services.

There are 2 differences when using this approach compared to the 'root' option value:

  1. The singleton service cannot be injected unless the providing Angular module has been imported.
  2. Lazy-loaded Angular modules and the AppModule create their own instances because of separate module injectors.

Guarding against multiple injectors

Assuming an Angular application with a single root Angular module, we can guard against module injectors creating multiple instances of a service. We do this by using a factory provider that resolves an existing instance or creates a new one.

// modern-singleton.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, Optional, SkipSelf } from '@angular/core';

import { ModernMixedModule } from './modern-mixed.module';

@Injectable({
  deps: [[new Optional(), new SkipSelf(), ModernSingletonService], HttpClient],
  providedIn: ModernMixedModule,
  useFactory: (instance: ModernSingletonService | null, http: HttpClient) => instance || new ModernSingletonService(http),
})
export class ModernSingletonService {
  constructor(private http: HttpClient) {}
}
Enter fullscreen mode Exit fullscreen mode
// modern-mixed.module.ts
import { NgModule } from '@angular/core';

import { MyComponent } from './my.component';

@NgModule({
  declarations: [MyComponent],
  exports: [MyComponent],
})
export class ModernMixedModule {}
Enter fullscreen mode Exit fullscreen mode

Modern singleton service guarded against multiple injectors.

This is the pattern used by Angular Material for its singleton services such as MatIconRegistry.

Just make sure that the providing module is imported as part of the root module injector. Otherwise, two lazy-loaded modules would still create two instances.

Stick to the root

Most of the time, using the 'root' option value is the easiest and least error-prone way of providing an application-wide singleton service.

In addition to being easier to use and reason about, the providedIn option of the Injectable decorator factory enables services to be tree-shakable as previously discussed.

Providing primitive values

Let’s imagine that we are tasked to display a deprecation notice to Internet Explorer 11 users. We’ll create an InjectionToken<boolean>.

This allows us to inject a boolean flag into services, components and so on. At the same time, we only evaluate the Internet Explorer 11 detection expression once per module injector. This means once for the root module injector and once per lazy-loaded module injector.

In Angular versions 4 and 5, we had to use an Angular module to provide a value for the injection token.

// is-internet-explorer.token.ts
import { InjectionToken } from '@angular/core';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');
Enter fullscreen mode Exit fullscreen mode
// internet-explorer.module.ts
import { NgModule } from '@angular/core';

import { isInternetExplorer11Token } from './is-internet-explorer-11.token';

@NgModule({
  providers: [
    {
      provide: isInternetExplorer11Token,
      useFactory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(navigator.userAgent),
    },
  ],
})
export class InternetExplorerModule {}
Enter fullscreen mode Exit fullscreen mode

Angular 4–5 dependency injection token with factory provider.

In Angular version 2, we could use an OpaqueToken similar to an InjectionToken but without the type argument.

Since Angular version 6, we can pass a factory to the InjectionToken constructor, removing the need for an Angular module.

// is-internet-explorer-11.token.ts
import { InjectionToken } from '@angular/core';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', {
  factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(navigator.userAgent),
  providedIn: 'root',
});
Enter fullscreen mode Exit fullscreen mode

Modern dependency injection token with value factory.

When using a factory provider, providedIn defaults to 'root', but let’s be explicit by keeping it. It’s also more consistent with how providers are declared using the Injectable decorator factory.

Value factories with dependencies

We decide to extract the user agent string into its own dependency injection token which we can use in multiple places and only read from the browser once per module injector.

In Angular versions 4 and 5, we had to use the deps option (short for dependencies) to declare factory dependencies.

// user-agent.token.ts
import { InjectionToken } from '@angular/core';

export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string');
Enter fullscreen mode Exit fullscreen mode
// is-internet-explorer.token.ts
import { InjectionToken } from '@angular/core';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');
Enter fullscreen mode Exit fullscreen mode
// internet-explorer.module.ts
import { Inject, NgModule } from '@angular/core';

import { isInternetExplorer11Token } from './is-internet-explorer.token';
import { userAgentToken } from './user-agent.token';

@NgModule({
  providers: [
    { provide: userAgentToken, useFactory: () => navigator.userAgent },
    {
      deps: [[new Inject(userAgentToken)]],
      provide: isInternetExplorer11Token,
      useFactory: (userAgent: string): boolean => /Trident\/7\.0.+rv:11\.0/.test(userAgent),
    },
  ],
})
export class InternetExplorerModule {}
Enter fullscreen mode Exit fullscreen mode

Angular 4–5 dependency injection token with value factory provider that declares dependencies.

Unfortunately, the dependency injection token constructor doesn’t currently allow us to declare factory provider dependencies. Instead, we have to use the inject function from @angular/core.

// user-agent.token.ts
import { InjectionToken } from '@angular/core';

export const userAgentToken: InjectionToken<string> = new InjectionToken('User agent string', {
  factory: (): string => navigator.userAgent,
  providedIn: 'root',
});
Enter fullscreen mode Exit fullscreen mode
// is-internet-explorer-11.token.ts
import { inject, InjectionToken } from '@angular/core';

import { userAgentToken } from './user-agent.token';

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag', {
  factory: (): boolean => /Trident\/7\.0.+rv:11\.0/.test(inject(userAgentToken)),
  providedIn: 'root',
});
Enter fullscreen mode Exit fullscreen mode

Modern dependency injection token with value factory that has dependencies.

The inject function injects dependencies from the module injector in which it’s provided—in this example the root module injector. It can be used by factories in tree-shakable providers. Tree-shakable class-based services can also use it in their constructor and property initialisers.


To resolve an optional dependency with inject, we can pass a second argument of InjectFlags.Optional. InjectFlags is in the @angular/core package and supports other injector options as bit flags.


In future Angular versions, inject will support more use cases like using a node injector.

Providing platform-specific APIs

To make use of platform-specific APIs and ensure a high level of testability, we can use dependency injection tokens to provide the APIs.

Let’s go with an example of Location (not the one from Angular). In browsers, it’s available as the global variable location and additionally in document.location. It has the type Location in TypeScript. If you inject it by type in one of your services, you might fail to realize that Location is an interface.

Interfaces are compile-time artifacts in TypeScript which Angular is unable to use as dependency injection tokens. Angular resolves dependencies at run-time so we must use software artifacts that are available at run-time. Much like a key for a Map or a WeakMap.

Instead, we create a dependency injection token and use it to inject Location into, for example, a service.

// location.token.ts
import { InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
Enter fullscreen mode Exit fullscreen mode
// browser.module.ts
import { NgModule } from '@angular/core';

import { locationToken } from './location.token';

@NgModule({
  providers: [{ provide: locationToken, useFactory: (): Location => document.location }],
})
export class BrowserModule {}
Enter fullscreen mode Exit fullscreen mode

Angular 4–5 dependency injection token with factory provider.

Like with a primitive value, we can create an injection token with a factory to get rid of the Angular module.

// location.token.ts
import { InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', {
  factory: (): Location => document.location,
  providedIn: 'root',
});
Enter fullscreen mode Exit fullscreen mode

Modern dependency injection token with API factory.

In the API factory, we use the global variable document. This is a dependency for resolving the Location API in the factory. We could create another dependency injection token, but it turns out that Angular already exposes one for this platform-specific API — the DOCUMENT dependency injection token exported by the @angular/common package.

In Angular versions 4 and 5, we would declare the dependency in the factory provider by adding it to the deps option.

// location.token.ts
import { InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
Enter fullscreen mode Exit fullscreen mode
// browser.module.ts
import { DOCUMENT } from '@angular/common';
import { Inject, NgModule } from '@angular/core';

import { locationToken } from './location.token';

@NgModule({
  providers: [
    {
      deps: [[new Inject(DOCUMENT)]],
      provide: locationToken,
      useFactory: (document: Document): Location => document.location,
    },
  ],
})
export class BrowserModule {}
Enter fullscreen mode Exit fullscreen mode

Angular 4–5 dependency injection token with API factory provider that declares dependencies.

As before, we can get rid of the Angular module by passing the factory to the dependency injection token constructor. Remember that we have to convert the factory dependency to a call to inject.

// location.token.ts
import { DOCUMENT } from '@angular/common';
import { inject, InjectionToken } from '@angular/core';

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API', {
  factory: (): Location => inject(DOCUMENT).location,
  providedIn: 'root',
});
Enter fullscreen mode Exit fullscreen mode

Modern dependency injection token with API factory that has dependencies.

Now we have a way of creating a common accessor for a platform-specific API. This will prove useful when testing declarables and services that rely on them.

Testing tree-shakable dependencies

When testing tree-shakable dependencies, it’s important to notice that the dependencies are by default provided by the factories passed as options to Injectable and InjectionToken.

To override tree-shakable dependencies, we use TestBed.overrideProvider, for example TestBed.overrideProvider(userAgentToken, { useValue: 'TestBrowser' }).

Providers in Angular modules are only used in tests when the Angular modules are added to the Angular testing module imports, for example TestBed.configureTestingModule({ imports: [InternetExplorerModule] }).

Do tree-shakable dependencies matter?

Tree-shakable dependencies don’t make a whole lot of sense for small applications where we should be able to pretty easily tell whether a service is actually in use.

Instead, imagine that we created a library of shared services used by multiple applications. The application bundles can now leave out the services that are not used in that particular application. This is useful both for monorepo workspaces and multirepo projects with shared libraries.

Tree-shakable dependencies are also important for Angular libraries. As an example, let’s say that we imported all the Angular Material modules in our application but only used some of the components and their related class-based services. Because Angular Material provides tree-shakable services, only the services we use are included in our application bundle.

Summary

We’ve looked at modern options for configuring injectors with tree-shakable providers. Compared to the providers in the pre-Angular 6 era, tree-shakable dependencies are often easier to reason about and less error-prone.

Unused tree-shakable services from shared libraries and Angular libraries are removed at compilation, resulting in smaller bundles.

Related articles

Tree-shakable dependencies is just one of the techniques used to make Angular modules optional. Read what you can expect from the upcoming Angular Ivy era in “Angular revisited: tree-shakable components and optional NgModules”.

Learn the ins and outs of Angular dependency injection in automated tests in “Testing and faking Angular dependencies”.

We’ll create a browser faker to test the banner component during development in “Faking dependencies in Angular applications”.

Peer reviewers

An enormous thank you to all of my fellow Angular professionals who gave me valuable feedback on this article 🙏

I meet wonderful, helpful people like these in the Angular communities.

Latest comments (3)

Collapse
 
chan_austria777 profile image
chan 🤖

What's the difference of functions/constants in a helpers or utilities folder w/c you can import anytime Vs value/factory providers? With functions if it has no dependencies then would it be better to just put it in a helpers folder?

Collapse
 
layzee profile image
Lars Gyrup Brink Nielsen

Thanks for your question, Chan.

Angular is built around dependency injection for inversion of control and testability. The difference is that we can replace those constants and functions in tests or even at runtime when we use an Angular dependency.

Collapse
 
chan_austria777 profile image
chan 🤖

I see, Thanks for the wonderful article.