Tree-shakable Dependencies in Angular Projects

Tree-shakable Dependencies in Angular ProjectsLars Gyrup Brink NielsenBlockedUnblockFollowFollowingJan 22Tree-shakable dependencies are easier to reason about.

Photo by Paul Green on Unsplash.

Tree-shakable dependencies enable smaller compiled 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 DependenciesWhen we use the providers option of the NgModule decorator factory to provide dependencies, the import statements at the top of the Angular module file refer to 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 ServicesA 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 ProvidersIn 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-Angular 6 singleton service.

Open in new tab.

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 ModulesWhen 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.

The forRoot pattern for singleton services.

Open in new tab.

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

Tree-shakable Singleton Service ProvidersFortunately, 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.

Open in new tab.

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 factory 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 to 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 forRoot alternative for singleton services.

Open in new tab.

There are 2 differences when using this approach compared to the 'root' option value:The singleton service cannot be injected unless the providing Angular module has been imported.

Lazy-loaded Angular modules and the AppModule create their own instance because of separate module injectors.

Guard Against Multiple InjectorsAssuming 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 guarded against multiple injectors.

Open in new tab.

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 RootMost 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 ValuesLet’s imagine that we are tasked to display a deprecation notice to Internet Explorer 11 users.

Let’s 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.

Angular 4–5 injection token with factory provider.

Open in new tab.

In Angular 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.

Modern injection token with value factory.

Open in new tab.

We could simply have passed a value to the useValue option to the injection token constructor instead.

This is another feature that was enabled in Angular version 6.

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.

We could have in-lined the factory as a lambda (arrow function) or function expression and the Angular build process would extract it from the provider and export it for us, giving us the same result.

The reason I’m declaring the factory as a function outside the provider options for the injection token is that it makes me able to reuse it in tests or even test the factory itself in tests.

We’ll get back to that later.

Value Factories with DependenciesWe decide to extract the user agent string into its own 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.

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

Open in new tab.

Unfortunately, the injection token constructor doesn’t currently allow us to declare provider factory dependencies.

Instead, we have to use the inject function from @angular/core.

Modern injection token with value factory that has dependencies.

Open in new tab.

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 services can also use it in their constructor and property initializers.

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 APIsTo make use of platform-specific APIs and ensure a high level of test-ability, we can use 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 an injection token and use it to inject Location into, for example, a service.

Angular 4–5 injection token with factory provider.

Open in new tab.

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

Modern injection token with API factory.

Open in new tab.

In the API factory, we use the global variable document.

This is a dependency for resolving the Location API in the factory.

We could add another injection token, but it turns out that Angular already exposes one for this platform-specific API — the DOCUMENT 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.

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

Open in new tab.

As before, we can get rid of the Angular module by passing the factory to the injection token constructor.

Remember that we have to convert the factory dependency to a call to inject.

Modern injection token with API factory that has dependencies.

Open in new tab.

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 DependenciesWhen 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.

SummaryWe’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 services from shared libraries and Angular libraries are removed at compilation, resulting in smaller bundles.

Peer ReviewersAn enormous thank you to all of my fellow Angular professionals who gave me valuable feedback on this article ????Alexey ZuevBrad TaniguchiJoost KoehoornKay KhanMahmoud AbduljawadMax Koretskyi, aka WizardSandra WillfordTim DeschryverTrotyl YuWassim Chegham.

. More details

Leave a Reply