Tree-shakable dependencies in Angular projects

Tree-shakable dependencies are easier to reason with and compile into smaller packages.

Angular modules used to be the primary way to provide application-wide dependencies such as constants, configurations, functions, and class-based services. Starting with Angular version 6, we can create tree-shakable dependencies and even ignore Angular modules.

Angular module providers create hard dependencies

When we supply dependencies using the providers option of the NgModule decorator factory, the import statement at the top of the Angular module file refers to the dependency file.

This means that all services provided in an Angular module become part of the package, even those that are not used by a Declarable or other dependency. Let’s call these hard dependencies because they can’t be tree shaken by our build process.

Instead, we can reverse the dependency by having the dependency file refer to the Angular module file. This means that even if an application imports an Angular module, it does not reference dependencies until it uses them in, for example, a component.

Providing singleton services

Many class-based services are referred to as application-wide singletons — or simply singletons, because we rarely use them at the platform injector level.

Pre-Angular 6 singleton service providers

In Angular versions 2 through 5, we must add singletons to the NgModule providers option. Then we must note that only eagerly loaded Angular modules will import provided Angular modules — by convention, this is our application’s CoreModule.

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

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

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

@NgModule({
  providers: [PreSixSingletonService],
})
export class PreSixModule {}
// 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 {}

This is the Pre-Angular 6 Singleton Service.

If we import the module that provides Angular in the lazy-loaded function module, we get different service instances.

Providing services in mixed Angular modules

When providing a service in an Angular module with a declarable, we should use the forRoot pattern to indicate that it is a hybrid Angular module — it provides both a declarable and a dependency.

This is important because importing an Angular module with a dependency provider into a lazy-loaded Angular module creates a new service instance for that module’s 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], }; }}

Above is The forRoot pattern for Singleton services.

The static forRoot method is used in our CoreModule as part of the root module injector.

Tree-shakable singleton service providers

Fortunately, Angular 6 adds the providedIn option to the Injectable decorator factory. This is an easier way to declare application-scoped singletons.

// 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) {}
}

This is the Modern Singleton Service.

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

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

Prior to Angular version 6, the Injectable decorator was technically unnecessary if our service had no dependencies. Still, it is considered a best practice to add it so that we don’t forget to do so when we add dependencies later.

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

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

providedIn: ‘root’

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

Alternatively, we can reference the providedIn option to an Angular module, similar to what we used to do with forRoot mode for mixed Angular modules, with some 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) {}
}
// modern-mixed.module.ts
import { NgModule } from '@angular/core';

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

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

A modern forRoot alternative to singleton services.

There are two differences in using this method compared to the ‘root’ option value:

  • The singleton service cannot be injected unless the provided Angular module has been imported.
  • Because of separate module injectors, lazily loaded Angular modules and AppModules create their own instances.

Providing primitive values

Suppose our task is to display a deprecation notification to Internet Explorer 11 users. We will create an InjectionToken< Boolean >.

This allows us to inject Boolean flags into services, components, and so on. Also, we only evaluate Internet Explorer 11 detection expressions once for each module injector. This means that the root module injector is loaded once and the lazy module injector is loaded once.

In Angular versions 4 and 5, we must use Angular modules to provide values for the injected token.

Create a token instance:

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

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');

Then create a new Module and use the Factory to specify what value the runtime should inject for the token:

// 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 {}

Angular 4-5 Dependency injection token with Factory Provider.

Angular 6 improvements:

Starting with Angular version 6, we can pass the factory to the InjectionToken constructor, eliminating the need for Angular modules.

// 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',
});

ProvidedIn defaults to “root” when using the factory provider, but let’s make it clear by leaving it. It is also more consistent with the way providers are declared using the Injectable decorator factory.

Value factories with dependencies

We decided to extract the User Agent string into its own dependency injection token, which we can use in multiple places, and each module injector only reads it once from the browser.

In Versions 4 and 5 of Angular, we must declare factory dependencies using the deps option (short for dependency).

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

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

export const isInternetExplorer11Token: InjectionToken<boolean> = new InjectionToken('Internet Explorer 11 flag');
Import {Inject, NgModule} from '@angular/core'; 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 {}

Unfortunately, the dependency injection token constructor currently does not allow us to declare factory provider dependencies. Instead, we must use the injection 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',
});
// 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',
});

This is an example of how to instantiate an Injection token with dependencies after Angular 6.

The injection function injects dependencies from the module injector that provides it — in this case, the root module injector. It can be used by factories in the tree-shakable provider. Tree-shakable class-based services can also use it in their constructors and property initializers.

Providing platform-specific APIs

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

Let’s look at an example of a Location. In the browser, it can be used as the global variable location, and in document.location. It has type Location in TypeScript. If you inject it by type in one of your services, you may not realize that Location is an interface.

Interfaces are TypeScript compile-time artifacts that Angular cannot 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 Map or WeakMap key.

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

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

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

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

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

That’s the old-fashioned way Angular 4-5 is written.

New Angular 6 writing:

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

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

In the API factory, we use the global variable document. This resolves the Location API dependency in the factory. We could have created 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 declare dependencies in factory providers by adding them to the deps option.

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

export const locationToken: InjectionToken<Location> = new InjectionToken('Location API');
// 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 {}

Here’s the new way to write it:

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

// 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',
});

Now we have a way to create common accessors for platform-specific apis. This will prove useful when testing the declarable and services that depend on them.

Testing tree-shakable dependencies

When testing tree-shakable dependencies, it is important to note that the dependencies are supplied by the factory by default and passed as options to Injectable and InjectionToken.

To override tree shaker dependencies, we use testbed.overrideProvider, such as TestBed.overrideProvider(userAgentToken, {useValue: ‘TestBrowser’}).

Presents the module provider only add presents module to the presents test was used to test when the module is imported, such as the TestBed. ConfigureTestingModule ({imports: [InternetExplorerModule]}).

Do tree-shakable dependencies matter?

Tree-shakable dependencies don’t make much sense for small applications, and it should be easy to determine if a service is actually in use.

Instead, suppose we create a shared service library that can be used by multiple applications. The application package can now ignore services that are not used in that particular application. This is useful for both Monorepo workspaces with shared libraries and multirepo projects.

Tree-shakable dependencies are also important for Angular libraries. For example, suppose we import all Angular Material modules into our application, but use only some components and their associated class-based services. Because the Angular Material provides tree-shaking services, our application package contains only the services we use.

Summary

We’ve looked at the modern options for configuring the injector using the tree-shakable provider. Shakable tree dependencies are generally easier to reason with and less error-prone than pre-Angular 6 providers.

Unused tree-shakable services from shared libraries and Angular libraries are removed at compile time, resulting in smaller packages.