angulartypescriptdependency-injectionangular-dependency-injectionangular-injector

Can we remove all the constructor-based injections?


Recently, Angular put in place a migration to replace constructor-based injection with inject function.

In most of the cases, it is straightforward and it clearly has benefits:

// before
@Injectable({ providedIn: 'root' })
export class SomeSearchService {
  constructor(
    @Inject(SEARCH_DUE_TIME)
    private readonly dueTime: number,
    private readonly repositoryService: SomeRepositoryService
  ) {}

// after: more concise, better typing
@Injectable({ providedIn: 'root' })
export class SomeSearchService {
  private readonly dueTime = inject(SEARCH_DUE_TIME);
  private readonly repositoryService= inject(SomeRepositoryService);

However, can inject function cover all the constructor-based injection usages?
In particular with useFactory + deps?

To illustrate, I have this Stackblitz:

// print.service.ts
@Injectable()
export class PrintService implements IPrintService {
  constructor( // QUESTION: can I replace it with `inject` calls? 
    @Inject(DATA_SERVICE)
    private readonly dataService: IDataService,
    private readonly logger: LoggingService,
    ...
  ) {}

// tokens.ts
export const DATA_SERVICE = new InjectionToken<IDataService>('DATA_SERVICE');
export const FOO_PRINT_SERVICE = new InjectionToken<IPrintService>('FOO_PRINT_SERVICE');
export const BAR_PRINT_SERVICE = new InjectionToken<IPrintService>('BAR_PRINT_SERVICE');

// app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: FOO_PRINT_SERVICE,
      useFactory: (dataService: IDataService, logger: LoggingService) => new PrintService(dataService, logger),
      deps: [FOO_DATA_SERVICE, LoggingService],
    },
    {
      provide: BAR_PRINT_SERVICE ,
      useFactory: (dataService: IDataService, logger: LoggingService) => new PrintService(dataService, logger),
      deps: [BAR_DATA_SERVICE, LoggingService],
    },
    ...,
  ],
};

Is there a way to use inject & useFactory + deps together
so that I can replace the constructor declaration in my PrintService with inject calls?


Solution

  • Most of the constructor-based injections are straightforwardly replaceable with the inject function:

    // before
    export class MyService {
      constructor(
        @Host() private readonly fooService: FooService,
        @Optional() private readonly barService: BarService,
        @Self() private readonly bazService: BazService,
        @SkipSelf() private readonly quxService: QuxService,
        @Inject(SOME_TOKEN) private readonly someToken: string,
        private readonly otherService: OtherService
      ) {}
    
    // after
    @Injectable()
    export class MyService {
      private readonly fooService = inject(FooService, { host: true });
      private readonly barService = inject(BarService, { optional: true });
      private readonly bazService = inject(BazService, { self: true });
      private readonly quxService = inject(QuxService, { skipSelf: true });
      private readonly someToken = inject(SOME_TOKEN);
      private readonly otherService = inject(OtherService);
    

    As mentioned previously, there is even a migration to change them automatically.

    The usecase of this question with useFactory + deps is a bit more tricky.
    The problematic is to create several instances of the same service at the same injection level
    (i.e.: ApplicationConfig.providers), but with different dependencies.

    In other words, we would need sort of injection scopes where inject(DATA_SERVICE) would point
    either to the FOO_DATA_SERVICE or to the BAR_DATA_SERVICE provided instance.

    To do so, it is possible to rely on Injector.create:
    it returns a brand new independent injectors with specific providers

    // print.service.ts
    @Injectable()
    export class PrintService implements IPrintService {
      private readonly dataService = inject(DATA_SERVICE);
      private readonly logger = inject(LoggingService);
    
    // app.config.ts
    export const appConfig: ApplicationConfig = {
      providers: [
        ...,
        {
          provide: FOO_PRINT_SERVICE,
          useFactory: (environmentInjector: EnvironmentInjector) => {
            const injector = Injector.create({
              providers: [
                { provide: DATA_SERVICE, useValue: { getData: () => of('foo') } },
                PrintService,
              ],
              parent: environmentInjector,
            });
            return injector.get(PrintService);
          },
          deps: [EnvironmentInjector],
        },
        {
          provide: BAR_PRINT_SERVICE,
          useFactory: (environmentInjector: EnvironmentInjector) => {
            const injector = Injector.create({
              providers: [
                { provide: DATA_SERVICE, useValue: { getData: () => of('bar') } },
                PrintService,
              ],
              parent: environmentInjector,
            });
            return injector.get(PrintService);
          },
          deps: [EnvironmentInjector],
        },
      ],
    };
    

    In this example, I was able to delete the FOO_DATA_SERVICE & BAR_DATA_SERVICE intermediate tokens.
    Note that the use of a parent injector (here EnvironmentInjector) is optional and depends on your needs.

    Stackblitz Demo

    Finally, you may want to recycle some intermediate services when using such "scoped" injectors.
    A technique for that can be to store those injectors in intermediate tokens:

    // common-repository.providers.ts
    export function provideCommonRepositoryFeature(payload: { ... }) {
      const injectorToken = new InjectionToken<Injector>(`COMMON_REPOSITORY_INJECTOR_${getUniqueId()}`);
      return makeEnvironmentProviders([
        {
          provide: injectorToken,
          useFactory: (environmentInjector: EnvironmentInjector) =>
            Injector.create({
              providers: [
                {
                  provide: COMMON_REPOSITORY_CONFIGURATION_FACADE, // Provide module's specificities
                  useValue: payload.configFacade,
                },
                CommonRepositoryApiEndpointService, // => this service will be recycled when providing the 2 target tokens below
                CommonRepositoryResultsApiMapperRepositoryService,
                CommonRepositoryResultsMockRepositoryService,
              ],
              parent: environmentInjector,
            }),
          deps: [EnvironmentInjector],
        },
    
        {
          provide: payload.targetApiMapperRepositoryToken,
          useFactory: (injector: Injector) => injector.get(CommonRepositoryResultsApiMapperRepositoryService),
          deps: [injectorToken],
        },
        {
          provide: payload.targetMockRepositoryToken,
          useFactory: (injector: Injector) => injector.get(CommonRepositoryResultsMockRepositoryService),
          deps: [injectorToken],
        },
      ]);
    }
    
    // app.config.ts
    export const appConfig: ApplicationConfig = {
      providers: [
        provideCommonRepositoryFeature({
          configFacade: { modulePath: 'foo' },
          targetApiMapperRepositoryToken: FOO_API_MAPPER_REPOSITORY,
          targetMockRepositoryToken: FOO_MOCK_REPOSITORY,
        }),
        provideCommonRepositoryFeature({
          configFacade: { modulePath: 'bar' },
          targetApiMapperRepositoryToken: BAR_API_MAPPER_REPOSITORY,
          targetMockRepositoryToken: BAR_MOCK_REPOSITORY,
        }),
      ],
    };