Angular 14 introduced a new inject()
function, offering an alternative to constructors for injecting dependencies.
export class MyComponent {
myService = inject(MyService);
}
This brought several advantages, such as:
Easier inheritance
inject
simplifies inheritance by eliminating the need to call super()
in subclasses and reducing boilerplate code. This is especially useful when dependencies are inherited:
export class A {
x = inject(X);
y = inject(Y);
}
export class B extends A {
z = inject(Z);
// No need for constructors,
// no need to inject X and Y,
// no need to call super()
}
Avoiding Parameter Decorators
Previously, when requesting a non-class dependency (e.g., an InjectionToken
), the @Inject()
decorator was often used:
export class A {
constructor(@Inject(X) x: SomeType) {}
}
With inject
, you can skip the decorator and enjoy automatic type inference, making the code more concise and type-safe:
export class A {
x = inject(X); // Works even with an InjectionToken
}
Additionally, inject
's second parameter lets you control options like Optional
, SkipSelf
, Self
, and Host
—all without needing extra decorators:
// Default options
x = inject(X, {
optional: false,
skipSelf: false,
self: false,
host: false
})
In short, inject
is a convenient utility. And as we've seen time and time again, convenience often wins over best practices.
This was my concern when it was first introduced: inject
could encourage practices that aren't ideal, especially for mid-level developers. As a consultant and trainer, I feared its misuse.
Now that a couple of years have passed, it’s a good time to reflect and see if these fears have materialized.
Classes lose their "purity"
One of the core principles of Dependency Injection (DI) is that dependencies are passed into the class, meaning the class doesn't need to "grab" them itself.
export class A {
// x is passed like a parameter
constructor(x: X) {}
}
This makes unit testing extremely simple, as it's easy to substitute the dependency with a mock or fake:
const x = getFakeX();
const a = new A(x);
// Do your tests...
With inject
, however, this simplicity is no longer guaranteed. Now, you must assume that any Angular class could be using inject, so you can no longer pass dependencies directly like this. Instead, you need to rely on Angular’s testing helper, TestBed
, which sets up an injector:
describe('A Component', () => {
let fixture: ComponentFixture<A>;
let component: A;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [A],
providers: [
{ provide: X, useValue: MockX }
]
});
fixture = TestBed.createComponent(A);
component = fixture.componentInstance;
});
});
While this introduces more verbosity, it’s manageable, and the simplicity of Angular’s DI system remains intact in terms of functionality. Moreover, TestBed
is necessary for testing the component's DOM, so I don't consider this a real drawback.
Scattered dependencies
Previously, all dependencies were neatly listed in the constructor, making them easy to spot. With inject
, however, dependencies can now be scattered throughout the class:
export class A {
x = inject(X);
// ... 100 lines of code later ...
y = inject(Y);
// ...
z = inject(Z);
}
In practice, developers often group their dependencies at the top of the class for clarity, so this issue rarely becomes a major problem. However, it does reduce the immediate visibility of dependencies within the class.
Hidden dependencies
The real concern arises with the ability to call inject
inside another function, but only if that function is invoked at construction time. This enables patterns like the following:
export class A {
x = thisFunctionUsesInject();
}
function thisFunctionUsesInject() {
const x = inject(X);
return x;
}
This pattern is gaining popularity due to its convenience. However, it comes with its own set of challenges:
- Hidden Dependencies: The dependency is now hidden inside a function, making it less obvious to anyone reading the code. They might expect the function to be usable anywhere, but if it's not executed during the construction phase, it will throw an error. This assumes the reader has knowledge of how Angular’s DI system works.
- Lack of Transparency: When inspecting the component, it's not immediately clear that there are any dependencies. To understand the component fully, you need to dive into the function that uses
inject()
. This introduces complexity, as the component may seem simpler than it actually is. Some developers have suggested naming these functions with an "inject" prefix to clarify the dependency injection relationship, but I don’t agree with that approach. Such naming could be misleading if the function returns something different than what is being injected. - Increased Testing Complexity: As with any other situation where dependencies are not directly visible, you'll still need to use TestBed for testing. But as established earlier, this is not a major issue in itself.
To wrap it up, while it's certainly a useful pattern, I suggest not to abuse it and use it with care, because it can make your code harder to grasp.
What is an Injection Context?
The inject
function can only be used within an Injection Context, but what exactly does that mean? In practice, this refers to specific places where Angular manages the lifecycle and context of a class or function, allowing it to resolve dependencies. These places include:
- Constructors
- Property initializers
- Guards
- Interceptors
useFactory
when declaring providers
It’s fairly straightforward to understand that inject
works during class construction because the class itself is being instantiated, and Angular has control over the DI context. However, the fact that it can also be used in places like guards or interceptors can be more confusing. This is a concern I’ve seen materialize frequently in my role as a consultant and trainer.
A particularly confusing scenario I’ve encountered involves functional NgRx Effects, where inject()
is used in a function which shouldn't have any arguments:
// Functional NgRx Effect injecting Actions
createEffect((actions$ = inject(Actions)) => actions$.pipe(...), {
functional: true
});
Here’s the trick: you have to understand that an Injection Context is automatically set up when NgRx creates the effect. The key to this pattern is that the inner function takes no arguments, so we exploit this by setting a default parameter, which just happens to be an injected dependency.
This pattern is not easy to grasp, especially for newcomers. The idea that inject
works without explicitly calling a constructor or using a class can be quite confusing, and many developers, particularly those new to Angular or DI systems, struggle with this.
Minor causes of frustration I've seen on the web
- Missed opportunity for a better name?: When
inject
was first introduced, some developers pointed out that it's actually the Injector (not inject itself) that performs the dependency injection. As a result, a few suggested that a different name, likeask
orretrieve
, would better reflect the function’s role. Personally, I don’t see this as a major issue. The name inject aligns with naming conventions in other frameworks, and it doesn’t cause any confusion for me. - Isn't this a Service Locator?: Another concern raised by some developers is that
inject
might resemble the Service Locator anti-pattern rather than true Dependency Injection. While it can be convincingly argued that inject is not a Service Locator (as highlighted in this insightful article), I do understand where the confusion stems from. The reason is thatinject
shares some of the same drawbacks as Service Locators, such as hidden dependencies and testing challenges.
Personally, I don’t believe inject
is a Service Locator, but it also doesn't feel like a "pure" form of Dependency Injection. It’s somewhere in between. But it’s worth noting that some of the same issues could arise even before inject
was introduced—simply by using the Injector
to retrieve dependencies directly. Perhaps even worse, but it was not a common pattern.
Takeaways
The new inject
function has proven to be incredibly useful, simplifying certain aspects of writing Angular code. Overall, I’m quite fond of it and generally recommend it over constructor parameters. I believe it’s valuable to adhere to a single, consistent practice, making the codebase easier to understand.
However, Angular developers need to invest some time in understanding what an Injection Context is and where inject
can be safely used. It also takes time to fully grasp some of the more subtle patterns, such as hidden dependencies or using dependencies as default values for function parameters.
Given these nuances, I believe that documentation websites should take extra care when explaining what constitutes an Injection Context. It’s important not to assume too much knowledge from developers and to clarify how and when inject
should be used.
AccademiaDev: text-based web development courses!
I believe in providing concise, valuable content without the unnecessary length and filler found in traditional books. Drawing from my years as a consultant and trainer, these resources—designed as interactive, online courses—deliver practical insights through text, code snippets, and quizzes, providing an efficient and engaging learning experience.
Author Of article : Michele Stieven Read full article