DEV Community

Cover image for Angular DI: the genetic tokens pitfall
Mikhail Istomin
Mikhail Istomin

Posted on

Angular DI: the genetic tokens pitfall

Intro

Recently I have discovered that using generic tokens for DI in Angular has an unexpected problem. It's not a critical issue, but rather something to be aware of.

In a nutshell, Angular and Typescript are not able to verify that a provided class really implements an interface, specified for a token.

App example

I declare the DI token PaymentServiceToken and provide the implementation on the module level.

export interface IPaymentService{
  getPaymentTypes(): void;
  authorizePayment(): void;
}

export const PaymentServiceToken = 
  new InjectionToken<IPaymentService>('Payment Service token')

@NgModule({
  ...
  providers: [
    {
      provide: PaymentServiceToken,
      useClass: PaymentService
    }
  ],
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Note that the token is initialised as a generic class to let the consumers know that the implementations for this token will implement the interface IPaymentService.

Then the service can be consumed by a component via DI like this

@Component({...})
export class PaymentComponent implements OnInit{

  constructor(
    @Inject(PaymentServiceToken) private paymentService:IPaymentService,
  ) {}

  // OR
  // private paymentService = inject(PaymentServiceToken);

  ngOnInit(): void {
    this.paymentService.getPaymentTypes();
  }
}
Enter fullscreen mode Exit fullscreen mode

As a result I expect interaction with the injected service to be type-safe. In case of service misuse the errors should be thrown in compilation time.

Type checking error

At the same time I can provide other implementations for the same token on the module level without making changes in the components level. Something like

@NgModule({
  ...
  providers: [
    {
      provide: PaymentServiceToken,
      // useClass: PaymentService
      useClass: environment.production 
        ? PaymentService 
        : MockPaymentService
    }
  ],
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

This approach is useful for some cases:

  • replace a real service with a mock version to avoid API calls
  • replace an old service with a new version having some extra features or refactoring
  • switching between implementations depending on configs or feature flags

Problem

To enable type checking I set up the token using the IPaymentService interface. But what if, by mistake, I provide the implementation that doesn't implement the interface?

export const PaymentServiceToken = 
  new InjectionToken<IPaymentService>('Payment Service token');

// DeliveryService  doesn't implement IPaymentService
class DeliveryService(){}

@NgModule({
  providers: [
    {
      provide: PaymentServiceToken,
      // useClass: PaymentService
      useClass: DeliveryService  // <--- wrong service !!!
    }
  ],
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

The result is a disaster. I have no errors in compile time. But in runtime the TypeError is thrown and the app is ruined

Unexpected runtime error

Providing the wrong service is for sure the developer's fault, but Typescript and Angular failed to detect it during compilation.

Solution

Unfortunately, I haven't found a way to arrange proper type checking to verify that the token's implementation meets the token's interface.

providers: [
  {
    provide: PaymentServiceToken,
    useClass: PaymentService // <--- be extremely cautious here
  }
],
Enter fullscreen mode Exit fullscreen mode

Moreover, there is an open issue in the Angular repo connected to the generic token's problem. Looks like there is no quick and simple solution.

The only thing I can suggest is to define a function with typing which verifies that the returned class implements the certain interface.

function getPaymentService(): (new () => IPaymentService) {
  return environment.production 
    ? PaymentService
    : MockPaymentService
}

@NgModule({
  providers: [
    {
      provide: PaymentServiceToken,
      useClass: getPaymentService()
    }
  ],
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

We can improve the function getPaymentService to make it suitable for handling services with DI in constructors

function getPaymentService(): (new (...args: any) => IPaymentService) {
  return environment.production 
    ? PaymentService
    : MockPaymentService
}
Enter fullscreen mode Exit fullscreen mode

Or rely on Angular's Type interface

function getPaymentService(): Type<IPaymentService> {
  return environment.production
    ? PaymentService
    : MockPaymentService
}
Enter fullscreen mode Exit fullscreen mode

This solution will throw a compilation error if I mistakenly provide a wrong service

Compilation error for a wrong service

Honestly, I don't like this solution since you have to write such functions manually for each generic token. I just can't come up with something better than that.

I hope the more useful solution will be introduced in the next versions of Angular.

Top comments (0)