Original cover photo by Markus Spiske on Unsplash.

Getting Started

Angular 14 introduced the inject function, giving developers a new way to pull in dependencies. It works wherever constructor-based injection is allowed—inside components, directives, and similar constructs. This opens the door to writing shared functions that can both be reused across parts of an app and tap into the dependency injection system. As an example, route data can be accessed directly inside a helper function, cutting down on boilerplate:

import { ActivatedRoute } from '@angular/router';

function getRouteParam(paramName: string): string {
  const route = inject(ActivatedRoute);
  return route.snapshot.paramMap.get(paramName);
}

@Component({
  selector: 'app-root',
    template: `
        <h1>Route param: {{ id }}</h1>
    `,
})
export class AppComponent {
  id = getRouteParam('id');
}
Enter fullscreen mode Exit fullscreen mode

With this approach, accessing route parameters no longer requires injecting ActivatedRoute into each component individually.

This capability is undeniably handy, but it naturally raises the following question:

Is it time to replace the constructor approach and rely on inject universally?

Let's weigh the arguments for and against.

Advantages

1. Code Reuse

As demonstrated above, inject enables logic to be shared across multiple components without repeatedly pulling in the same dependencies. This shines in projects where many components rely on identical services. Any reusable piece of logic that depends on an injection token—whether it's a service, a value, or something else—can now be implemented as a plain function instead of being locked into a class. Functions often provide a simpler and more adaptable structure, though that's a matter of perspective.

2. Type Inference

In the past, creating a class that relied on an injection token meant explicitly declaring the type of the property that held the injected value. That step is now unnecessary, since the type is automatically derived from the token itself. This proves especially helpful when working with InjectionToken, which no longer requires the @Inject decorator. Consider this example:

import { InjectionToken } from '@angular/core';

const TOKEN = new InjectionToken<string>('token');

@Component({
    // component metadata
})
export class AppComponent {
    constructor(
        @Inject(TOKEN) token: string,
        // we had to explicitly define 
        // the type of the token property
    ) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

This version is not only more wordy but also hinges entirely on the type we manually assign to token. The modern equivalent is much cleaner:

import { InjectionToken } from '@angular/core';

const TOKEN = new InjectionToken<string>('token');

@Component({
    // component metadata
})
export class AppComponent {
    private token = inject(TOKEN);
    // type "string" is inferred
}
Enter fullscreen mode Exit fullscreen mode

Naturally, this same type inference applies to services and almost any injectable.

3. Simpler Inheritance

Deriving new Angular components or directives from other classes has often been cumbersome, particularly when injected dependencies are involved. The root of the issue lies in constructor injection: every dependency has to be forwarded to the parent constructor, leading to duplicated code that grows more complex with deeper inheritance trees. Here's a typical scenario:

export class ParentClass {
    constructor(
        private router: Router,
    ) {
        // ...
    }
}

@Component({
    // component metadata
})
export class ChildComponent extends ParentClass {
    constructor(
        // we have to inject all the 
        // parent dependencies again
        // and add others
        private router: Router,
        private http: HttpClient,
    ) {
        super(router); // also pass to the parent
    }
}
Enter fullscreen mode Exit fullscreen mode

With inject, constructors can be bypassed entirely, retrieving dependencies directly where they're needed. This is a major relief for long inheritance chains, as there's no need to thread dependencies through parent constructors. The result looks like this:

export class ParentClass {
    private router = inject(Router);
}

@Component({
    // component metadata
})
export class ChildComponent extends ParentClass {
    private http = inject(HttpClient);
}
Enter fullscreen mode Exit fullscreen mode

We'll now cover the final benefit that inject brings to the table:

4. Custom RxJS Operators

By using inject, it's possible to build custom RxJS operators that have access to dependency injection. This is very useful for operators that rely on a service, as it removes the burden of injecting that service into each component and passing it along. Here's how it works without inject:

function toFormData(utilitiesService: UtilitiesService) {
    return (source: Observable<any>) => {
        return source.pipe(
            map((value) => {
                return utilitiesService.toFormData(value);
            }),
        );
    };
}

@Component({
    // component metadata
})
export class AppComponent {
    constructor(
        private utilitiesService: UtilitiesService,
    ) {
        // ...
    }

    private formData$ = this.http.get('https://example.com').pipe(
        toFormData(this.utilitiesService),
    );
}
Enter fullscreen mode Exit fullscreen mode

Supplying the service every time the operator is used quickly becomes monotonous, especially when the operator also takes other parameters. Now, compare that with the inject-based version:

function toFormData() {
    const utilitiesService = inject(UtilitiesService);
    return (source: Observable<any>) => {
        return source.pipe(
            map((value) => {
                return utilitiesService.toFormData(value);
            }),
        );
    };
}

@Component({
    // component metadata
})
export class AppComponent {
    private formData$ = this.http.get('https://example.com').pipe(
        toFormData(),
    );
}
Enter fullscreen mode Exit fullscreen mode

It's tidy and concise! The flexibility of RxJS grows significantly with this method.

Disadvantages

The drawbacks are relatively minor, but they deserve a mention.

1. Novelty

As an API, inject has only recently been made available for direct import and use. It doesn't have a direct equivalent in many other frameworks, which could make it less familiar to some developers. This is a minor hurdle, though, since the concept is simple to grasp and its usage is likely to become more widespread.

2. Context Limitations

The function's utility is restricted to dependency injection contexts. Attempting to call it in a model or DTO class, for example, will trigger an error. The Angular documentation provides more details on this constraint. A workaround exists in the form of the runInContext API, which can be used like this:

@Component({
    // component metadata
})
export class AppComponent {
   constructor(
    private injector: EnvironmentInjector,
   ) {}

   ngOnInit() {
       this.injector.runInContext(() => {
           const token = inject(TOKEN);
           // use the token freely outside of the constructor
       });
   }
Enter fullscreen mode Exit fullscreen mode

For further reading, check out Nethanel Basal's piece: Getting to Know the runInContext API in Angular.

3. Testing

Perhaps the most significant drawback is the added complexity in testing. If you've been manually creating service instances with the new keyword to avoid TestBed, you'll find that's no longer possible when those services depend on inject. This effectively necessitates the use of TestBed in your test setup.

Summary

Each new Angular version comes packed with novel features and innovative strategies for tackling common problems. This overview should provide a clearer understanding of the inject function and demonstrate how it might be applied within your own codebase.