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;
}
}
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,
]
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();
}
}
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:
- Create
ILogger
interface withlog
method. - Create
LOGGER
injection token and passILogger
service as its generic type. - Inject
LOGGER
token inCoinTossService
and call itslog
method. - 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");
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;
}
}
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,
},
]
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.`);
}
}
},
]
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",
};
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 ValueProvider
s.
function createProvider<T>(
token: InjectionToken<T>,
value: T
): ValueProvider {
return {
provide: token,
useValue: value,
};
}
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"
);
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 ValueProvider
s 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,
};
}
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" }
);
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
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 }),
]
Final thoughts:
-
InjectionToken<T>
is really handy when you want to configure or add new functionality to existing module/service/component. -
InjectionToken<T>
is generic andinject()
function will infer its type automatically. -
Provider
is not generic therefore it is possible to provide anything to anyInjectionToken
(even if the generic type ofInjectionToken
is provided). - 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