NestJS tip: multi-value providers almost like `multi` from Angular

Micael Levi L. C. - Feb 5 '23 - - Dev Community

for NestJS v8, v9 and v10

You can see and play with the full code here.


Angular's dependency injection mechanism has a feature called Multi Providers, which looks like this:

// =============== Angular code  =============== //
import { Component, InjectionToken, Inject } from '@angular/core'
const TOKEN = new InjectionToken<string>('MyToken')
@Component({
  // ...
  providers: [
    { provide: TOKEN, useValue: 'foo', multi: true },
    { provide: TOKEN, useValue: 'bar', multi: true },
  ],
})
class AppComponent {
  constructor(@Inject(TOKEN) public myValues: string[]) {
    console.log(myValues) // outputs ["foo", "bar"]
  }
}
Enter fullscreen mode Exit fullscreen mode

NestJS doesn't come with that capability but we can implement it (kinda).

The final version I'm proposing here looks like this:

final result

module definition

terminal output

we have a factory function called provide that receives an array of 'enhanced' providers and return providers array but registering all providers with the same token under a multi-value provider as long as it has multi: true.

Disclaimer

The solution I'm about to show is intended to be as simple as possible, thus it doesn't work exactly like multi providers from Angular. I encourage you to use this mental model to build a better solution :)

Known limitations:

  • Only merges providers registered within the same module
  • Does not validate if there is a multi provider with the same token as one non-multi
  • Does not take in count providers with scopes other than the default (singleton lifestyle). I didn't tested such scenario
  • Dependency not found errors may look a bit cryptic now due to how the provider is being registered under the hood
  • Not work with forwardRef, but it should be feasible to have
  • as mentioned in the comments of this post, you cannot use this when sharing the same provider token for multiple modules

Problem

NestJS providers consists of two main parts: a value and a unique token. We can see that with custom providers, which are just objects that have the properties provide (provider's token) and some value that will be defined based on which kind of provider you got (namely: useClass, useExisting, useValue or useFactory). You can learn more on this subject here.

To retrieve some provider NestJS's DI system uses its token (in a given context). So although we can declare multiple providers with the same token, only the latest one will be returned when we inject that provider into others providers.

We want to register multiple providers under the same token, and we should have an easy API for this. When retrieving that provider, we should get an array of values.

Solution

Leverage on multi: true API from Angular and implement one version of that feature by using the useFactory custom provider as suggested by Kamil here.

The overall ideia here is:

  1. Group all providers marked with multi: true by their common token
  2. For each provider in that group, register it using some unique token to avoid naming collisions. And save this token in that group for later usage
  3. For each entry in that group, register a new provider with useFactory that injects all of the collected providers at step (2) and return an array of them

I'll do only 2 iterations for performance sake: one to collect and save multi-value providers while registering non-multi ones, as usual; and other to create the final factory provider for each group.

The provide helper function is defined as follows:

// provide.util.ts
import type {
  Type,
  ClassProvider,
  ValueProvider,
  FactoryProvider,
  ExistingProvider,
  Provider,
  InjectionToken,
} from "@nestjs/common"

type EnhancedProvider<T = any> =
  Type<T>
| ((
    ClassProvider<T> |
    ValueProvider<T> |
    FactoryProvider<T> |
    ExistingProvider<T>
  ) & { multi?: true })

export function provide(providers: EnhancedProvider[]): Provider[] {
  /** The final providers list that we should pass to some module. */
  const providersToRegister: Provider[] = []
  /** A map with all multi-value providers with their common token. */
  const multiValueProviderTokensByGroupToken = new Map<InjectionToken, InjectionToken[]>()

  for (const provider of providers) {
    if ('multi' in provider && provider.multi) {
      const currProviderToken = provider.provide

      const providerTokens = multiValueProviderTokensByGroupToken.get(currProviderToken) || []

      // Create a unique token for this provider so that it can be injected later
      const uniqueToken: InjectionToken = `multi-provider at idx ${providerTokens.length + 1} with token ${currProviderToken.toString()}`
      providerTokens.push(uniqueToken)
      multiValueProviderTokensByGroupToken.set(currProviderToken, providerTokens)

      // Register the provider but using the unique token instead
      providersToRegister.push({
        ...provider,
        provide: uniqueToken,
      })
    } else {
      // Non-multi provider, so just register it as-is
      providersToRegister.push(provider)
    }
  }

  for (const [providerGroupToken, tokensToInject] of multiValueProviderTokensByGroupToken) {
      providersToRegister.push({
      provide: providerGroupToken,
      inject: tokensToInject,
      useFactory: (...providers) => providers,
    })
  }

  return providersToRegister
}
Enter fullscreen mode Exit fullscreen mode
. . . . . . . . . . . . .
Terabox Video Player