This piece was put together with heavy assistance from Claude.ai.
The cover image belongs to Graphue.
Angular's DI framework is robust, yet deciding between constructor injection and the more recent inject() function can make a real difference in how clean your code turns out. Thanks to runInInjectionContext(), dependency resolution now offers us additional flexibility.
Below, we look at five distinct situations in which inject() — often paired with runInInjectionContext — outshines the classic constructor approach.
1. Standalone Functions (Route Guards, Interceptors)
Problem: Wiring dependencies via constructor forces you to wrap simple functions in classes.
@Injectable({
providedIn: 'root'
})
export class AuthGuard implements CanActivate {
constructor(
private router: Router,
private authService: AuthService
) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean {
return this.authService.isLoggedIn() || this.router.navigate(['/login']);
}
}
Workaround: functions can tap into dependencies via inject().
import { inject } from '@angular/core';
import { Router } from '@angular/router';
export function authGuard(): boolean {
const router = inject(Router);
const authService = inject(AuthService);
return authService.isLoggedIn() || router.navigate(['/login']);
}
Key Benefits:
Reduced boilerplate in your code
No need for extra class definitions
Improved tree-shaking potential
This is now the officially recommended practice in Angular.
You can see it applied in functional route guards.
2. Factory Functions (Creating Customized Services)
This scenario closely mirrors the earlier one, but its usage targets a different goal: building service instances with tailored configuration, all handled through a functional style.
import { inject, InjectionToken } from '@angular/core';
// Token for different logger instances
export const USER_LOGGER = new InjectionToken<Logger>('user.logger');
export const SYSTEM_LOGGER = new InjectionToken<Logger>('system.logger');
// A factory that creates specialized logger instances
export function createLogger(category: string, minLevel: 'debug'|'info'|'error') {
const configService = inject(ConfigService);
return {
debug: (msg: string) => {
if (minLevel === 'debug' && configService.isDebugEnabled) {
console.log(`[${category}][DEBUG] ${msg}`);
}
},
info: (msg: string) => {
if (minLevel === 'debug' || minLevel === 'info') {
console.log(`[${category}][INFO] ${msg}`);
}
},
error: (msg: string) => {
console.error(`[${category}][ERROR] ${msg}`);
}
};
}
// Creating provideFunction
export function provideUserLogger(minLevel: 'debug'|'info'|'error') {
return {
provide: USER_LOGGER,
useFactory: () => createLogger('USER', minLevel)
}
}
export function provideSystemLogger(minLevel: 'debug'|'info'|'error') {
return {
provide: SYSTEM_LOGGER,
useFactory: () => createLogger('SYSTEM', minLevel)
}
}
// Register in config file which will be used for Angular pp bootstrapping
export const appConfig: ApplicationConfig = {
providers: [
// Other providers...
ConfigService,
provideUserLogger('info'),
provideSystemLogger('error')
]
};
Requiring developers to manually provide ConfigService whenever they want SYSTEM_LOGGER or USER_LOGGER is cumbersome, making it much cleaner to have provideSystemLogger and provideUserLogger handle that automatically:
export function provideUserLogger(minLevel: 'debug'|'info'|'error') {
return [
ConfigService,
{
provide: USER_LOGGER,
useFactory: () => createLogger('USER', minLevel),
}]
}
export function provideSystemLogger(minLevel: 'debug'|'info'|'error') {
return [
ConfigService,
{
provide: SYSTEM_LOGGER,
useFactory: () => createLogger('SYSTEM', minLevel),
}]
}
You can try it out in this demo.
3. Lazy Injection (On-Demand Service Loading)
With constructor-based DI, every service gets instantiated right away—even those that might never be touched. The inject() function, however, makes it possible to defer that instantiation.
This is achieved via runInInjectionContext. That utility establishes an injection context at runtime, so you can call inject() outside of a component's initialization phase.
import { Component, inject, Injector, runInInjectionContext } from '@angular/core';
import { HeavyDataService } from './services';
@Component({
selector: 'app-heavy',
template: '...'
})
export class HeavyComponent {
// Store the injector itself
private injector = inject(Injector);
// Service is not injected at initialization
private loadHeavyData() {
// Create injection context when needed
return runInInjectionContext(this.injector, () => {
// Now we can safely use inject()
const heavyService = inject(HeavyDataService);
return heavyService.fetchData();
});
}
onUserAction() {
// Heavy service only injected when this is called
const data = this.loadHeavyData();
// Process data...
}
}
Seems fine at first glance. But my colleague pointed out that achieving actual laziness for HeavyDataService still demands an import statement — a wrinkle that caught me off guard, I admit.
private loadHeavyData() {
// Create injection context when needed
return runInInjectionContext(this.injector, () => {
// Now we can safely use inject()
return import('./services').then(({ HeavyDataService }) => {
const heavyService = inject(HeavyDataService);
return heavyService.fetchData();
});
}
4. Multi-Level Inheritance (Avoiding Constructor Hell)
Honestly, extending component classes in Angular isn't my cup of tea—I lean toward composition over inheritance. It often creates more complexity than the DRY payoff justifies. Still, if that’s your style:
@Component({...})
export class BaseComponent {
protected readonly router = inject(Router);
}
@Component({...})
export class MiddleComponent extends BaseComponent {
protected readonly userService = inject(UserService);
}
@Component({...})
export class ChildComponent extends MiddleComponent {
// Has access to both `router` and `userService`
// No complex constructor chaining needed!
}
Benefits:
The
super()call becomes unnecessary.Inheritance turns tidier and simpler to keep up with.
With
inject()and the same token, a subclass can replace an inherited service and get its own version.
5. Dynamic Providers (Runtime Dependency Switching)
Static dependencies are a must for constructor injection. With inject(), services can be chosen on the fly.
This situation closely mirrors the Lazy service instantiation scenario from #3, though it layers additional conditional checks onto it.
import { Component, inject, Injector, runInInjectionContext } from '@angular/core';
import { FeatureFlagService, NewImplementationService, LegacyImplementationService } from './services';
@Component({
selector: 'app-dynamic',
template: '...'
})
export class DynamicComponent {
private injector = inject(Injector);
private featureFlag = inject(FeatureFlagService);
// Don't inject services yet
private service: any;
constructor() {
// Get the right service based on feature flag
this.service = this.getService();
}
private getService() {
return runInInjectionContext(this.injector, () => {
// Now we can conditionally inject
if (this.featureFlag.isNewFeatureEnabled) {
return inject(NewImplementationService);
} else {
return inject(LegacyImplementationService);
}
});
}
doSomething() {
this.service.method();
}
}
6. Type Inference with inject() vs Constructor DI
Constructor-based DI forces you to declare the type explicitly when consuming InjectionTokens. It’s also surprisingly easy to annotate the wrong type — Angular/TypeScript offers no validation that the declared annotation actually matches the token’s or class’s underlying type.
// Define a token with a type
const CONFIG = new InjectionToken<AppConfig>('app.config');
@Component({...})
class MyComponent {
constructor(
// Must manually specify the type here
@Inject(CONFIG) private config: AppConfig
) {}
}
Beyond that, constructor parameter decorators fall outside the ECMAScript Decorators specification. Once the TypeScript compiler drops the experimentalDecorators flag, they will no longer be available.
With inject(), the token determines the return type, so explicit typing becomes unnecessary:
typescript// Define a token with a type
const CONFIG = new InjectionToken<AppConfig>('app.config');
@Component({...})
class MyComponent {
// Type is automatically inferred as AppConfig
private config = inject(CONFIG);
ngOnInit() {
// TypeScript knows this is AppConfig
console.log(this.config.apiUrl);
}
}
7. ES2022 alterations and the impending phase-out of TypeScript's 'useDefineForClassFields' setting.
Jeremy Elbourn, who leads the Angular team, shared the following details in a response on GitHub:
On top of that, we are introducing a fresh guideline: opt for the inject method instead of constructor parameter injection
This suggestion comes about because ECMAScript 2022 now includes class fields.
Below is the content borrowed directly from Jeremy Elbourn's GitHub post:
A straightforward example looks like this:
@Component({ /* ... */ })
export class UserProfile {
private user = this.userData.getCurrent();
constructor(private userData: UserData) { }
}
As long as TypeScript compiles to any ECMAScript release prior to ES2022, this setup operates without issue. Under those conditions, the resulting JavaScript takes the following shape:
// Emitting ES2017
export class UserProfile {
constructor(userData) {
// The field initializer is inlined into the constructor
this.userData = userData;
this.user = this.userData.getCurrent();
}
}
But when targeting ES2022, things change. Thanks to the useDefineForClassFields flag, the generated code now takes this form:
// Emitting ES2022
export class UserProfile {
userData;
user = this.userData.getCurrent(); // Error! userData is not yet initialized!
constructor(userData) {
this.userData = userData;
}
}
The generated output fails because a field initializer executes prior to the constructor, attempting to access a dependency that simply isn't set yet. When relying on constructor injection, the fix involves restructuring your code as follows:
@Component({ /* ... */ })
export class UserProfile {
// Field declaration is separated from initialization.
private user: User;
constructor(private userData: UserData) {
this.user = userData.getCurrent();
}
}
For a lot of people, splitting field declarations from their initialization feels awkward. The inject function, though, offers a clean way around that difficulty:
@Component({ /* ... */ })
export class UserProfile {
private userData = inject(UserData);
private user = this.userData.getCurrent();
}
1. Beyond the Injection Context (details here)
Specific instances include the following:
a) Within Asynchronous Operations
@Component({...})
class MyComponent {
constructor() {
// This will fail
setTimeout(() => {
const service = inject(MyService); // ERROR
}, 1000);
}
}
b) Inside Event Handlers
@Component({
template: '<button (click)="handleClick()">Click</button>'
})
class MyComponent {
handleClick() {
// ERROR: No injection context in event handler
const service = inject(MyService);
}
}
c) In Subscription Callbacks
@Component({...})
class MyComponent {
constructor() {
const observable = inject(DataService).getData();
observable.subscribe(data => {
// ERROR: No injection context in subscription callback
const logger = inject(LoggerService);
logger.log(data);
});
}
}
d) Standalone Functions Devoid of runInInjectionContext
// Standalone utility function
export function formatData(data: any) {
// ERROR: No injection context
const formatter = inject(FormatterService);
return formatter.format(data);
}
e) Calling inject(...) inside ngOnInit fails unless runInInjectionContext wraps it.
// doesn't work
ngOnInit(): void {
// We need to use runInInjectionContext here
this.serviceB = inject(MyService);
}
// works
ngOnInit(): void {
runInInjectionContext(this.injector, () => {
this.serviceB = inject(MyService);
});
}
Every one of the preceding scenarios gets handled uniformly by passing the invocation through the runInInjectionContext wrapper.
2. Within Plain (Non-Angular) Classes
// Regular class, not managed by Angular DI
class RegularClass {
constructor() {
// ERROR: No injection context
const service = inject(SomeService);
}
}
Final Verdict
For typical components and services, constructor injection remains the default choice, yet switching to
inject()becomes worth considering when an es2022 migration is on the horizon.In unconventional scenarios—functions, factories, lazy loading, inheritance, or dynamic providers—
inject()is the go-to approach.
More to read:
- "The inject function is not a service locator" by Matthieu Riegler
- Interesting drawback of using inject function in Angular 16+ (I did not check it on newer versions). Case: Angular 16+ pipe used with the template child component input value. Drawback: if in pipe we inject ChangeDetectorRef with 'inject' function: cdRef will be from ChildComponent; but if we inject ChangeDetectorRef using constructor injection, it will be from the ParentComponent.
Enjoyed this read? Connect with me on Twitter!
