Explicit and type-safe Angular module configuration
Daan Scheerens - Jan 3, 2020
Sharing is caring.
Angular's architecture and build system makes it easy to apply this saying by creating reusable modules. Whether it being just for yourself, your team, the whole organization or even the entire world: it is always valuable to develop something which has the potential to be used more than once.
The extend to which a module is reusable depends in part on the set of configuration options offered by the module. For example if it needs to communicate with a backend then it is probably a good idea to make the endpoint URLs configurable. Hard coding them in the module itself will make the module unsuitable for more than one environment.
URLs are just a simple (but common) example of configuration. In practice a whole variety of different kind of configuration options can observed, rangeing from simple scalar values, composite data structures, functions to complete services! Because configuration plays a key role in reusability, it would be nice if users of your module can perform the configuration in a simple and safe manner.
This article will show you how to do create a public API for your Angular modules/libraries that is:
Easy to use
Type-safe
Based on dependency injection
Module configuration using Angular's DI framework
In order to make a module configurable a mechanism is needed to tranfser this configuration to the different parts of a module (services, components, directives, etc.) The mechanism that makes this possible is dependency injection.
Let's explore how this works in practice: Angular's router module. Anyone that has worked with the Angular router knows that it does not hard code the available routes. Instead, we must configure these ourselves. By inspecting the "heart" of this module, the Router
service, we can see that the route configuration is injected via the constructor:
1 2 3 4 5 6 7 8 9 10export class Router { // ... constructor( // ... a bunch of parameters of which the last one is: public config: Routes ) { /* ... */ } // ... }
Users of the router module must specify the route configuration using either the RouterModule.forRoot
or RouterModule.forChild
function. For those not familiar with the difference between these two functions, you can read all about in the excellent blog Sharing Angular modules like a boss. Both functions return an object that complies to the ModuleWithProviders
interface. This is actually a shorthand for importing a module together with some additional providers (the latter not being provided by default by the module itself).
Returning to the example of the router module we can see that the forRoot
and forChild
functions make use of the provideRoutes
utility function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15export class RouterModule { // ... static forChild(routes: Routes): ModuleWithProviders<RouterModule> { return {ngModule: RouterModule, providers: [provideRoutes(routes)]}; } } export function provideRoutes(routes: Routes): any { return [ {provide: ANALYZE_FOR_ENTRY_COMPONENTS, multi: true, useValue: routes}, {provide: ROUTES, multi: true, useValue: routes}, ]; } // ...
The provideRoutes
function takes care of generating the correct providers for the route configuration. A nice thing about this setup is that as a user of the Router module, we do not need to know about the ANALYZE_FOR_ENTRY_COMPONENTS
and ROUTES
injection tokens. Neither need we know that these should be multi providers. In essence this is form of encapsulation: the details about how to correctly setup the providers are hidden from users of the Router module. This is a good thing: it makes the configuration of the router module simpler and less susceptible to errors.
Two important observations can be made from the way the Angular router module handles configuration:
It is explicit: the
forRoot
andforChild
functions exactly show what configuration is expected by the module. Instead of having to read through documentation to find out which configuration options are available most information can be found simply by inspecting the function signature. If your IDE supports JS/ESDoc it can even show documentation for these options. As a bonus we also don't have to bother with setting up the correct provider definitions for the configuration options.It is type-safe: we cannot simply pass any value, e.g. a number literal, as route configuration. TypeScript helps us to prevent the mistake of providing an invalid configuration.
If you intend to develop your own reusable Angular module that require some form of configuration, then I highly recommend adding a static function to your module that accepts the configuration as parameter and returns a
ModuleWithProviders
object. This shields your users from having to manually setup the correct provider definitions and also makes it a type-safe solution.
As a side note: you don't have to call these static functions either forRoot
or forChild
. That is just a convention used when modules need to be imported differently depending on whether they are used in a (potentionally) lazy loaded route (forChild
) or within the module that is bootstrapped (forRoot
). When this distinction is not relevant then I personally choose not to follow this convention and call the function withConfiguration
or some other name that aligns well with the functionality offered by the module.
Configuration dependencies
Configuring Angular's router module is actually one of the simpler cases: you can only specify static data here. But things aren't always that simple: sometimes configuration data needs to be dynamically resolved. To study this, let's have a look at another example: the ngx-translate
library. Similarly to the Angular Router, this library also exposes forRoot
and forChild
static methods on the TranslateModule
, and thereby enabling us developers to provide the configuration for this library:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16export class TranslateModule { static forRoot(config: TranslateModuleConfig = {}): ModuleWithProviders { /* ... */ } static forChild(config: TranslateModuleConfig = {}): ModuleWithProviders { /* ... */ } } export interface TranslateModuleConfig { loader?: Provider; compiler?: Provider; parser?: Provider; missingTranslationHandler?: Provider; isolate?: boolean; useDefaultLang?: boolean; }
The interesting thing to see here is that the configuration contains several optional properties which are typed as Provider
. One of these is the loader
, which specifies the provider used to resolve the TranslateLoader
that is to be used by the TranslateService
to dynamically load translations. This illustrates that modules sometimes offer extension/customization points by accepting complete services as part of their configuration. Since such services themselves might also require configuration they have to be specified as a Provider
in order to make use of Angular's DI features.
If you want your own module to support dynamically resolved configuration options, then you can define those properties as Provider
in your configuration model. But, there is a better way...
Provider
type in configuration models
Limitations of Angular's Using the Provider
type in configuration models unfortunately comes at the cost of type-safety:
Angular's
Provider
type is lacking a type argument that specifies what kind of value is resolved by the provider.
This means that using ngx-translate
we could write the following without compile errors:
1 2 3TranslateModule.forRoot({ loader: { provide: TranslateLoader, useValue: 'just a string, not a TranslateLoader' } })
At runtime, however, this will lead to an unpleasant error when the TranslateService
attempts to load the translations. It would be nice if the TypeScript compiler could already warn us here.
To make matters worse, by using the Provider
type in configuration models, we are effectively losing some of the module's encapsulation. We need to know the details of how to correctly specify the provider: which provider token to use and whether it should be a multi-provider or not. Since the Provider
type poses no restrictions on what is provided, you are not required to specify a provider that resolves a TranslateLoader
. The following will compile without errors (in JIT mode):
1 2 3TranslateModule.forRoot({ loader: { provide: FoodService, useClass: CookieService } })
In JIT mode (default for development) you will only notice something is wrong at runtime when Angular reports that it cannot find a provider for TranslateLoader
.
By switching to the
Provider
type in configuration models one sacrifices type-safety and encapsulation in favor of dynamic resolution.
Don't compromise
As demonstrated, using Angular's Provider
type in configuration models comes at a cost. This raises the question if there is an alternative solution to supporting dynamic resolution, that does not sacrifice type-safety and encapsulation. The answer is yes, but not with Angular's standard API. We have to craft some tools ourselves to make this possible.
Basically we need a way to separate the definition of providers in two parts:
The token which is used as qualifier for what needs to be injected (and whether it should be multi-provider).
A definition of how a value for that token should be resolved.
Let's call the latter definition an unbound provider, as it describes how to resolve a value, but not for what token, i.e. it is not bound to a token (yet). The UnboundProvider
type definition is more or less equal to Angular's Provider
type, except that the provide
and multi
properties have been stripped off. Since we have to define the UnboundProvider
type ourselves, we can just as well make it type-safe by adding a type parameter T
, representing the type of value it resolves.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26import { InjectionToken, Type } from '@angular/core'; export type UnboundProvider<T> = UnboundValueProvider<T> | UnboundClassProvider<T> | UnboundExistingProvider<T> | UnboundFactoryProvider<T>; export interface UnboundValueProvider<T> { useValue: T; } export interface UnboundClassProvider<T> { useClass: Type<T>; } export interface UnboundExistingProvider<T> { useExisting: Token<T>; } export interface UnboundFactoryProvider<T> { useFactory(...deps: any[]): T; deps?: any[]; } export type Token<T> = InjectableClassToken<T> | InjectionToken<T>; export type InjectableClassToken<T> = Function & { prototype: T };
With the definition of UnboundProvider
, this type can now be used instead of Provider
in configuration models. For ngx-translate
, the configuration model then be rewritten as:
1 2 3 4 5 6 7 8export interface TranslateModuleConfig { loader?: UnboundProvider<TranslateLoader>; compiler?: UnboundProvider<TranslateCompiler>; parser?: UnboundProvider<TranslateParser>; missingTranslationHandler?: UnboundProvider<MissingTranslationHandler>; isolate?: boolean; useDefaultLang?: boolean; }
If this was the actual model used by the ngx-translate
libary, then the two earlier examples of incorrectly defined providers for the loader
property would no longer be possible. Typescript would simply return a compile error because the type of the used provider values cannot be reconciled with the UnboundProvider<TranslateLoader>
type.
Type-safety: check!
But what about encapsulation? Actually, that has been taken care of as well. The UnboundProvider
type does not include the provide
and multi
properties, which are exactly the details users of the module should not be bothered with. Instead it is the responsibility of module itself to specify those, as they are only relevant for the internal workings of the module. This, however, does mean that a final ingredient is necessary to make this a fully working solution.
That ingredient comes in the form of the bindProvider
function. It is the responsibility of this function to bind an UnboundProvider
to a token and turn it into a fully defined Provider
, which can then be used in the providers
array of a module (or ModuleWithProviders
object). The signature of bindProvider
function is as follows:
1 2 3 4 5 6 7 8export function bindProvider<T, U extends T>( token: Token<T>, unboundProvider: UnboundProvider<U> | undefined, options: { multi?: boolean, default?: UnboundProvider<U> } = {} ): Provider { /* ... */ }
Apart from the token and unbound provider it also has an optional third parameter, options
, which can be used to define the provider as multi-provider and to define a default in case the unbound provider is optional in the configuration model. The full implementation of the bindProvider
function is quite lengthy since it has to be compatible with Angular's AOT compiler. When interested, you can find the implementation as part of the provider binding feature in the ngx-inject
library.
To see how the bindProvider
is used, let's apply it to the ngx-translate
library. The following code demonstrates how the TranslateModule.forRoot
would look like if it were to make use of the UnboundProvider
type in the configuration model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25export class TranslateModule { static forRoot(config: TranslateModuleConfig = {}): ModuleWithProviders { return { ngModule: TranslateModule, providers: [ bindProvider(TranslateLoader, config.loader, { default: { useClass: TranslateFakeLoader } }), bindProvider(TranslateCompiler, config.compiler, { default: { useClass: TranslateFakeCompiler } }), bindProvider(TranslateParser, config.parser, { default: { useClass: TranslateDefaultParser } }), bindProvider( MissingTranslationHandler, config.missingTranslationHandler, { default: { useClass: FakeMissingTranslationHandler } } ), TranslateStore, {provide: USE_STORE, useValue: config.isolate}, {provide: USE_DEFAULT_LANG, useValue: config.useDefaultLang}, TranslateService ] }; } static forChild(config: TranslateModuleConfig = {}): ModuleWithProviders { /* ... */ } }
Summary
When developing reusable modules/libraries you'll want to keep the public API as clean and simple as possible. That also applies to configuration, since it is part of the public API. Within Angular the best way of achieving this is by exposing a public static function on the module that accepts the configuration and returns a object that conforms to the ModuleWithProviders
interface. This makes the configuration options explicit and type-safe.
Angular's router module effectively applies this approach with the RouterModule.forRoot
and RouterModule.forChild
functions. The ngx-translate
library shows a more complex type of configuration: one that is dynamically constructed via dependency injection. Unfortunately Angular has no out-of-the-box solution to support such configuration requirements without sacrificing two important properties: type-safety and encapsulation.
It is possible retain these properties by separating Angular's Provider
type into its constituent parts:
The what: an (injection) token
The how: a type-safe definition of how a value for an (injection) token is resolved.
This separation makes it possible to only expose the model that describes the how (called an "unbound provider") in the configuration model, while leaving details such as which token and whether is a multi-provider up to the module itself. A full implementation of this concept can be found in the ngx-inject
library.