Dependency injection in Angular libraries

Useful patterns for parameterizing Angular modules

Matej ChalkMatěj ChalkFull-stack engineer | FlowUp

Angular - dependency injection

When using multiple Angular projects in your codebase (e.g. when using an Nx monorepo), it is important to maintain a clean dependency tree. One major anti-pattern is for a library to import from your app (causes a circular dependency and your library is no longer generic). However, you often need your library to have access to some app-specific details. These may be provided in two ways:

  1. Pass in a parameter on a per-usage basis (i.e. for every occurrence of the library's component/pipe/directive in a template, or every invocation of the library's service/function). This is trivial, but not suitable for all cases - see next point.

  2. Inject parameters globally when importing the library. This is much more elegant when a parameter is static within your app, but it is not so obvious how to best implement it. Therefore, this will be the focus of the rest of the article.

Parameterizing libraries imported in AppModule

This section is for libraries whose module is imported once in the root module. Typically, this is the case for libraries that export a provider (i.e. a service that can then be injected throughout the app), or an NgRx feature module (state management for a store slice).

Example - SEO service

In this example, we have a library that exports an SEOModule, providing an SEOService that can be used to update meta tags on route changes. It also takes care of setting all the OpenGraph and Twitter tags, and is able to provide some generic defaults (e.g. determining URL for og:url). Since this is something multiple apps can take advantage of, we want this library to be generic. But the library needs to be provided some app-specific information, e.g. a title for the website, canonical URL, or Facebook App ID. This configuration is global by nature, so it's best to provide it in the AppModule. The convention used for such cases in external libraries is for the library module to have a static forRoot method, where the necessary parameters can be passed in.

import { SEOModule } from '<path/to/library>'; @NgModule({ imports: [ // ... SEOModule.forRoot({ siteTitle: 'My Awesome Website', canonicalUrl: 'www.myawesomewebsite.com', fbAppId: '123456789' }), ], // ... }) export class AppModule {}

app.module.ts

The library module then has to take care of providing these values internally so that its services etc. have access to them. This can be done via dependency injection - the library module's forRoot return value will be of type ModuleWithProviders (built-in type from @angular/core) in order to provide these values as internal dependency injection tokens. The library implementation looks something like this:

export interface SEOConfig { siteTitle: string; canonicalUrl?: string; fbAppId?: string; // ... }

types.ts

import { SEOConfig } from './types'; /* * - Since this token is for internal purposes only, we would not export * this file outside the library. * - Creating the DI token in a separate file helps prevent * circular dependencies. */ export const SEO_CONFIG_TOKEN = new InjectionToken<SEOConfig>('SEO_CONFIG');

di.ts

import { SEOService } from './seo.service'; import { SEOConfig } from './types'; import { SEO_CONFIG_TOKEN } from './di'; @NgModule({ imports: [CommonModule], providers: [SEOService], }) export class SEOModule { static forRoot(config: SEOConfig): ModuleWithProviders { return { ngModule: SEOModule, providers: [{ provide: SEO_CONFIG_TOKEN, useValue: config }], } } }

seo.module.ts

import { SEO_CONFIG_TOKEN } from './di'; import { SEOConfig } from './types'; @Injectable() export class SEOService { constructor( // ... @Optional() @Inject(SEO_CONFIG_TOKEN) private readonly config: SEOConfig | null, ) { /* * Since it's possible users of your library will forget to call forRoot(), * we mark the DI token as optional using the built-in decorator * (to prevent a 'No provider for ...' error). * In these cases the constructor parameter will be null, * but we could provide sensible defaults. * It might also be a good idea to print a warning to the console * in such cases. You might not want to use the @Optional decorator * if default values wouldn't make any sense, however. * Either way, make sure to describe the setup in your README! */ } updateMetaTags(args: { pageTitle?: string, /* ... */ }) { /* you can access the injected config here, e.g.: */ const siteTitle = this.config && this.config.siteTitle; const title = args.pageTitle ? `${args.pageTitle} - ${siteTitle}` : siteTitle; // ... } }

seo.service.ts

Parameterizing libraries imported in component modules

The forRoot solution is convenient for libraries that are imported once in the root module. However, if a library is exporting a UI element (component/pipe/directive), it is a common pattern to import the library's module only in the modules of components that are using it (to take advantage of lazy loading). Having to remember to import the module via forRoot (with the exact same values each time) would be quite tiresome and error-prone. A better solution in this case is to also export the library's DI token, and instruct library users to provide the token in the AppModule, in the understanding that this will then apply globally to all library module imports throughout the app.

Example - Relative date pipe with i18n support

In our next example, we have a library with a RelativeDatePipeModule, which exports a pipe for formatting dates based on how long ago they occurred. This pipe receives a date as input, compares it to the current date and then returns a string like '5 hours ago' or 'yesterday at 4 PM'. A key feature of this pipe is internationalization, so that it's also usable for different languages. It would be inconvenient to have to pass in a language parameter for each usage (either as a secondary parameter or when importing the pipe's module). Instead, we would like apps to be able to integrate it like this:

import { DATE_LANG_TOKEN } from '<path/to/library>'; @NgModule({ // ... provides: [ // ... /* you could also lazy load modules with different languages provided */ { provide: DATE_LANG_TOKEN, useValue: environment.lang }, ], }) export class AppModule {}

app.module.ts

import { RelativeDatePipeModule } from '<path/to/library>'; @NgModule({ declarations: [FooComponent], imports: [ // ... RelativeDatePipeModule, /* no forRoot() needed */ ], exports: [FooComponent], }) export class FooModule {}

foo.module.ts

The internal library implementation is then very similar to the previous example.

export enum Lang { EN = 'en', CS = 'cs', // ... }

types.ts

import { Lang } from './types'; /* this DI token is part of the library's public API, so should be exported */ export const DATE_LANG_TOKEN = new InjectionToken<Lang>('DATE_LANG');

di.ts

import { RelativeDatePipe } from './relative-date.pipe'; @NgModule({ declarations: [RelativeDatePipe], imports: [CommonModule], exports: [RelativeDatePipe], }) export class RelativeDatePipeModule {}

relative-date-pipe.module.ts

import { DATE_LANG_TOKEN } from './di'; import { Lang } from './types'; @Pipe({ name: 'relativeDate' }) export class RelativeDatePipe implements PipeTransform { readonly lang: Lang; constructor( @Optional() @Inject(DATE_LANG_TOKEN) lang: Lang | null, ) { this.lang = lang || Lang.EN; } transform(date: Date | string | number): string { /* here you can access this.lang */ } }

relative-date.pipe.ts

Conclusion

Angular's dependency injection mechanism provides an elegant solution for parameterizing Angular libraries when importing them. The forRoot convention should be preferred when suitable, but a looser approach can also be used (just make sure it's well documented). It's also important to differentiate between required and optional dependencies - missing parameter errors should be reported as clearly as possible, but for most parameters it is better if the library can fall back on sensible defaults.

Can you feel the flow?

Drop us a note

hello@flowup.cz

Contact us using this form and we'll answer you via email ASAP. If you leave us your number in the message, we'll call you back. Looking forward to hearing from you!

We're based in Brno

Kopečná 980/43

Brno

602 00

Czech Republic

map