Angular DI: Type safe Angular providers

. - Aug 17 - - Dev Community

In this article we will explore Angular Dependency Injection mechanism. We will also try to answer the following question: "Is it possible to define Angular providers in a type-safe way?".

Introduction

Defining Angular providers is a common way of configuring our services and components. Let's implement a simple CoinToss service. Its only responsibility is to return the result of a single coin toss.

export const enum CoinSide {
  HEADS = "heads",
  TAILS = "tails",
}

@Injectable()
export class CoinTossService {
  public toss(): CoinSide {
    return Math.random() > 0.5 
      ? CoinSide.HEADS 
      : CoinSide.TAILS;
  }
}
Enter fullscreen mode Exit fullscreen mode

In order to inject this service, we need to provide it first. We can do it on a component level or lazy-loading (routing configuration) level.

providers: [
  CoinTossService,
]
Enter fullscreen mode Exit fullscreen mode

And now in a component:

@Component({ ... })
export class MyComponent {
  public coinSide: CoinSide | null = null;
  private readonly coinTossService = inject(CoinTossService);

  public updateCoinSide() {
    this.coinSide = this.coinTossService.toss();
  }
}
Enter fullscreen mode Exit fullscreen mode

Please note that the type of this.coinTossService is automatically inferred to CoinTossService by inject() function.

New requirements

So far so good. Now, let's imagine that we want to add logging functionality to our CoinTossService. Also we want to keep things decoupled as much as possible. We can use an InjectionToken for this. The procedure usually looks like this:

  1. Create ILogger interface with log method.
  2. Create LOGGER injection token and pass ILoggerservice as its generic type.
  3. Inject LOGGER token in CoinTossService and call its log method.
  4. Define a provider connected to LOGGER injection token.

Let's code:

export interface ILogger {
  log(coinSide: CoinSide): void;
}

export const LOGGER = new InjectionToken<ILogger>("coinToss.logger");
Enter fullscreen mode Exit fullscreen mode

Modified version of CoinTossService:

@Injectable()
export class CoinTossService {
  private logger = inject(LOGGER, { optional: true });

  public toss(): CoinSide {
    const coinSide = Math.random() > 0.5 
      ? CoinSide.HEADS 
      : CoinSide.TAILS;

    this.logger?.log(coinSide);

    return coinSide;   
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the fun part. In order to link everything together and make CoinTossService log coin toss result, we need to provide a value to LOGGER injection token. Let's go back to providers array:

@Injectable()
class MyLogger implements ILogger {
  public log(coinSide: CoinSide): void {
    console.log(`The coin landed on ${coinSide} side.`);
  }
}

providers: [
  CoinTossService,
  {
    provide: LOGGER,
    useClass: MyLogger,
  },
]
Enter fullscreen mode Exit fullscreen mode

Great! Logging functionality is working as expected!

The problem

I have decided to define a ClassProvider, but you can go ahead and create any kind of provider, e.g. ExistingProvider, FactoryProvider or ValueProvider. In fact, let's do it together. I will define ValueProvider now instead:

providers: [
  CoinTossService,
  {
    provide: LOGGER,
    useValue: {
      logMessage(coinSide: CoinSide): void {
        console.log(`The coin landed on ${coinSide} side.`);
      }
    }
  },
]
Enter fullscreen mode Exit fullscreen mode

Hmm, something is wrong. My application compiles with no errors but then it fails in runtime. Oh, that's right. It seems that I made a typo and instead of log method, I implemented logMessage method. I will fix that in a moment.

But wait. Isn't it disturbing that the application successfully compiles but eventually breaks when we try to interact with it? Why is this happening and is there anything we can do to prevent this? Let's start with the "why".

In Angular, the InjectionToken<T> type is generic and you can pass a corresponding value type to it. This is why the inject function can infer its return type. The LOGGER injection token is correctly associated with ILogger interface and calling inject(LOGGER) is guarantied to return value of type ILogger.

Unfortunately, unlike InjectionToken<T> type, the Provider type is not generic. So there is no mechanism to check whether the value you are providing matches the Typescript type of an injection token. Let's see that in action:

const SOME_NUMBER = new InjectionToken<number>("number");
const numberProvider: Provider = {
  provide: SOME_NUMBER,
  useValue: "Absolutely not a number",
};
Enter fullscreen mode Exit fullscreen mode

Although "Absolutely not a number" is indeed not of type number, this is a completely valid provider definition and Typescript will not complain about it.

The solution

So now we know why it happened. But can we fix it? Actually yes, we can, but in order to do so, we will have to implement a little helper function on our own. Let's call it createProvider.

We need a way to keep provider's value type and injection token's type in sync. We can achieve this with generic function. Let's start with something really simple and assume that we only care for ValueProviders.

function createProvider<T>(
  token: InjectionToken<T>, 
  value: T
): ValueProvider {
  return {
    provide: token,
    useValue: value,
  };
}
Enter fullscreen mode Exit fullscreen mode

Our helper createProvider() takes two arguments: injection token and a value you want to "connect" with it. You may think that this function doesn't do anything but in fact it keeps generic type T in sync. Let's try to break it:

const SOME_NUMBER = new InjectionToken<number>("number");
const numberProvider = createProvider(
  SOME_NUMBER,
  "Absolutely not a number"
);
Enter fullscreen mode Exit fullscreen mode

And there it is: [ERROR] TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

Perfect! So it seems our little helper is doing pretty well. But for now it supports ValueProviders exclusively. I really don't like the idea of creating 3 more similar helpers for each type of Angular providers.

Thankfully, we don't have to do it. We can define a helper type for every provider and pass it to the createProvider function instead of value: T parameter.

type ProviderRecipe<T> =
  | { useValue: T }
  | { useClass: Type<T> }
  | { useExisting: Type<T> }
  | { useFactory: () => T };

function createProvider<T>(
  token: InjectionToken<T>,
  recipe: ProviderRecipe<T>
): Provider {
  return {
    provide: token,
    ...recipe,
  };
}
Enter fullscreen mode Exit fullscreen mode

Ok cool, let's try the same experiment and check if the new version of our function works:

const SOME_NUMBER = new InjectionToken<number>("number");
const numberProvider = createProvider(
  SOME_NUMBER,
  { useValue: "Absolutely not a number" }
);
Enter fullscreen mode Exit fullscreen mode

Yes! We still got the same Typescript error. In fact we will get the same error for all available provider types. Let's try that with ILogger interface and LOGGER injection token:

const LOGGER = new InjectionToken<ILogger>("coinToss.logger");

createProvider(
  LOGGER,
  { 
    useValue: { 
      wrongMethodName() {} 
    } 
  }
); // ERROR

class LogerWithWrongMethodName {
  public wrongMethodName(): void {}
}
createProvider(
  LOGGER,
  { 
    useClass: LogerWithWrongMethodName,
  }
); // ERROR

createProvider(
  LOGGER,
  { 
    useFactory: () => null,
  }
); // ERROR

Enter fullscreen mode Exit fullscreen mode

And that's it! Now we can create our providers with 100% confidence that any type mismatch will be thrown during compilation time.

Let's take a look at final version of providers array:

@Injectable()
class MyLogger implements ILogger {
  public log(coinSide: CoinSide): void {
    console.log(`The coin landed on ${coinSide} side.`);
  }
}

providers: [
  CoinTossService,
  createProvider(LOGGER, { useClass: MyLogger }),
]
Enter fullscreen mode Exit fullscreen mode

Final thoughts:

  1. InjectionToken<T> is really handy when you want to configure or add new functionality to existing module/service/component.
  2. InjectionToken<T> is generic and inject() function will infer its type automatically.
  3. Provider is not generic therefore it is possible to provide anything to any InjectionToken (even if the generic type of InjectionToken is provided).
  4. We can fix that using custom generic createProvider<T>() function that checks if the value type is in sync with injection provider type.

Thank you for your time. I hope you find it useful. If something was unclear or you just have a question please don't hesitate and leave a comment.

Whole example on StackBlitz: https://stackblitz.com/edit/stackblitz-starters-9d7h2n?file=src%2Fmain.ts

.