Angular DI Deep Dive: InjectionToken, @Optional, Multi-Providers, and the Resolution Algorithm [2026]

Link copied
Angular DI Deep Dive: InjectionToken, @Optional, Multi-Providers, and the Resolution Algorithm [2026]

Angular DI Deep Dive: InjectionToken, @Optional, Multi-Providers, and the Resolution Algorithm [2026]

Lesson 1.3 was the consumer side of Angular's DI: inject(), the root injector, providedIn: 'root'. That covers 90% of the code you write. This lesson is the 10% that comes back to bite you in a real codebase — InjectionToken<T> for non-class values, the @Optional/@Self/@SkipSelf/@Host modifiers, multi-providers, factory providers, and the exact algorithm the injector follows to resolve a token.

This is lesson 1.7 of the Angular Tutorial, and the deeper of the two DI lessons in Module 1. You should be comfortable with inject() and providedIn before reading on.

The provider grammar in one table #

Everything you can put in a providers array (in appConfig, on a component, or on a route) is one of six shapes:

Shape Example What it means
Class shorthand MyService "Provide MyService as itself, with new MyService()"
useClass { provide: MyService, useClass: MockMyService } "When someone asks for MyService, give them a MockMyService instead"
useValue { provide: API_URL, useValue: 'https://api.example.com' } "Use this literal value (no constructor)"
useExisting { provide: NewLogger, useExisting: OldLogger } "Alias — return whatever the injector has for OldLogger"
useFactory { provide: CONFIG, useFactory: () => loadConfig() } "Run this function once, cache the return value"
EnvironmentProviders bundle provideHttpClient(...) A function returns an array of providers that all register together

A component or service constructor parameter declaration is the consumer shape. The six rows above are all registration shapes. The injector's job is to match a consumer request (a token) to a registration.

InjectionToken<T> — for non-class values #

The inject(HttpClient) call works because HttpClient is a class and TypeScript can use it as both a value (the constructor) and a type. For non-class values — strings, numbers, config objects, callbacks — you need a real token:

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

export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL');

export interface FeatureFlags {
  beta: boolean;
  experimental: boolean;
}
export const FEATURE_FLAGS = new InjectionToken<FeatureFlags>('FEATURE_FLAGS');

The string argument ('API_BASE_URL') is a debug label that shows up in stack traces. The generic (<string>, <FeatureFlags>) is what inject(API_BASE_URL) will return.

// Register
providers: [
  { provide: API_BASE_URL, useValue: 'https://api.example.com' },
  { provide: FEATURE_FLAGS, useValue: { beta: true, experimental: false } },
]

// Consume
const url = inject(API_BASE_URL);          // typed as string
const flags = inject(FEATURE_FLAGS);       // typed as FeatureFlags

Tokens are how you avoid hard-coding configuration into your services. The service depends on API_BASE_URL (a token); the environment supplies the value. Tests substitute a different value. Refactoring is one change.

inject() modifier options #

The inject() function takes a second argument — a small set of flags that change what happens if the lookup fails or the search scope:

// Don't throw if the token has no provider — return null instead
const maybeLogger = inject(LoggerService, { optional: true });

// Only look in THIS component's own injector, don't walk up
const localState = inject(FeatureStateService, { self: true });

// Start the search at the PARENT injector, skip this one
const parentTheme = inject(ThemeService, { skipSelf: true });

// Stop at the host component boundary (don't escape into the host)
const slotData = inject(SLOT_DATA, { host: true });

In pre-v14 code these were decorators (@Optional(), @Self(), @SkipSelf(), @Host()) applied to constructor parameters. The flag-object syntax replaces them. Both still work.

When do you actually need these?

Modifier Real use case
optional: true A logger your library exports — caller may or may not provide one
self: true Forcing the lookup to use the provider on this component (catches accidental inheritance)
skipSelf: true A directive that wants the parent's form state, not its own
host: true A directive embedded in a slot that should resolve relative to the host component, not the slot

The everyday Angular service does not need any of these. They are tools for library authors and directive authors. Recognize the syntax when you see it; reach for the flags only when the default lookup is wrong.

Multi-providers — "give me all of them" #

A multi-provider registers multiple values under the same token; inject() returns the array of registrations:

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

export interface Greeter { (name: string): string }
export const GREETERS = new InjectionToken<Greeter[]>('GREETERS');

// Register from anywhere — each registration is appended
providers: [
  { provide: GREETERS, multi: true, useValue: (n: string) => `Hello, ${n}` },
  { provide: GREETERS, multi: true, useValue: (n: string) => `Hi, ${n}` },
  { provide: GREETERS, multi: true, useValue: (n: string) => `Yo, ${n}` },
]

// Consume — get all three
const greeters = inject(GREETERS);   // typed as Greeter[]
console.log(greeters.map(g => g('Pradeep')));
// → ['Hello, Pradeep', 'Hi, Pradeep', 'Yo, Pradeep']

Angular uses multi-providers itself: HTTP_INTERCEPTORS is a multi-token, and each withInterceptors([...]) call appends to the array. Same shape for route guards (CanActivate), error handlers in custom pipelines, and plugin registries.

The rule: every registration of a multi-provider token must include multi: true. Mix a multi and a non-multi for the same token and Angular throws at bootstrap.

Factory providers — when the value needs computation #

useFactory runs a function once (the first time the token is requested) and caches the return value. Use it when the value depends on another service or on async startup state:

export const HTTP_CLIENT_WITH_AUTH = new InjectionToken<HttpClient>(
  'HTTP_CLIENT_WITH_AUTH',
);

providers: [
  {
    provide: HTTP_CLIENT_WITH_AUTH,
    useFactory: () => {
      const http = inject(HttpClient);
      const auth = inject(AuthService);
      // Configure http instance with auth
      return http;
    },
  },
]

Three facts about factories:

  1. Factories run inside an injection context. You can inject() other tokens inside the factory body. No deps: array is needed any more (that was the pre-v14 syntax).
  2. Factories run once per injector. The return value is cached.
  3. Factories cannot be async. They run synchronously during injector construction. If you need async startup, use provideAppInitializer() instead.

The resolution algorithm (the real one) #

When you call inject(SomeToken), Angular runs this exact procedure:

1. Start at the consumer's injector (the component, directive, or service).
2. Check that injector's providers for SomeToken.
3. If found → instantiate (or return cached) → done.
4. If not found AND the consumer is a component:
     a. Walk up the element-injector chain (parent component → ancestor → ...)
     b. At each level, check for SomeToken.
     c. First hit wins.
5. If still not found AND a route boundary is in the chain:
     a. Check that route's providers.
6. If still not found:
     a. Check the root injector (everything from appConfig, plus every providedIn:'root' service).
7. If still not found:
     a. Throw NullInjectorError (unless optional:true was passed).

Three details that catch people:

  • Lazy-loaded routes create their own injector tier. A service providedIn: 'root' is visible from every lazy chunk; a service registered only on a lazy route is NOT visible to the rest of the app.
  • Element injectors are component-scoped, not template-scoped. A <div> is not an injector; only components and directives are.
  • The self: true flag truncates step 4 to step 2 only. No walking up.

Understanding this algorithm is what separates using DI from debugging DI. When inject() returns the "wrong" instance, the answer is always somewhere in this seven-step walk.

Tree-shakable providers #

A service marked @Injectable({ providedIn: 'root' }) is tree-shakable: if nothing in the import graph references it, the bundler drops it from the production build. That is why providedIn was added — the legacy providers: [...] in an NgModule could not be tree-shaken because Angular had to register everything eagerly.

The shape that survives tree-shaking:

@Injectable({ providedIn: 'root' })
export class UnusedService { /* dropped if no one injects it */ }

The shapes that don't tree-shake:

// Always registered, always in the bundle
// appConfig.ts
providers: [
  EagerlyRegisteredService,                  // shorthand
  { provide: T, useClass: ConcreteImpl },    // explicit
]

For services you might or might not need, @Injectable({ providedIn: 'root' }) is the right registration site. For configuration values where the value itself is small (a string, an object literal), the appConfig.providers array is fine — the value is in the bundle either way.

Mocking in tests #

DI's biggest practical payoff is testability. To swap any service in a test:

import { TestBed } from '@angular/core/testing';

TestBed.configureTestingModule({
  providers: [
    { provide: HttpClient, useClass: FakeHttpClient },
    { provide: API_BASE_URL, useValue: 'http://localhost:0' },
  ],
});

const service = TestBed.inject(MyService);   // service sees the fakes

No mocks library, no monkey-patching imports. The injector resolves each token the same way as in production — TestBed just provides a different providers array. Module 11 covers TestBed in depth.

Common gotchas #

Symptom Cause Fix
Mixed multi-provider for token X One registration used multi: true, another did not, both targeting the same token Make all registrations of that token multi-providers, or none
Cyclic dependency in DI detected Service A injects Service B which injects Service A Either break the cycle (extract a third service) or use inject(B, { optional: true }) + lazy .get()
NullInjectorError despite the provider being registered The provider is on a child component, but the consumer is in a parent Move the provider up, or move the consumer down, or use useExisting on a parent to alias
Service has weird state across tests The test setup forgot to re-configure TestBed; a singleton survived TestBed.resetTestingModule() between tests, or rely on Vitest's beforeEach
Lazy route's service not visible Lazy routes get their own injector — root code can't see the lazy-route provider Either move the provider to providedIn: 'root', or accept the scoped behavior

What's next #

Lesson 1.4 takes a different angle — the TypeScript essentials an Angular developer hits every day: decorators, generics for Signal<T> and inject(), type narrowing inside @if, the strict-mode flags worth turning on. Lesson 1.5 then walks the build pipeline (ng build, esbuild + Vite, AOT, source maps, budgets) to close out Module 1.

After that, Module 2 starts — components, templates, pipes, directives, the everyday surface. With DI behind you, every service you inject in Module 2 will feel routine.

Try it yourself #

Here is a one-file demo of multi-providers — registers three Greeter functions and injects the array:

import { Component, InjectionToken, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

type Greeter = (name: string) => string;
const GREETERS = new InjectionToken<Greeter[]>('GREETERS');

@Component({
  selector: 'app-root',
  template: `
    <ul>
      @for (g of greeters; track $index) {
        <li>{{ g('Pradeep') }}</li>
      }
    </ul>
  `,
})
class App {
  greeters = inject(GREETERS);
}

bootstrapApplication(App, {
  providers: [
    { provide: GREETERS, multi: true, useValue: (n: string) => `Hello, ${n}` },
    { provide: GREETERS, multi: true, useValue: (n: string) => `Hi, ${n}` },
    { provide: GREETERS, multi: true, useValue: (n: string) => `Yo, ${n}` },
  ],
});

Delete multi: true from one of the three registrations and watch the console — Angular tells you exactly what it does not like.

YouI have a logger service in a parent component and a child also defines one. Which one does inject(LoggerService) return in the child?
Claude · used search_documentationThe child’s instance. Angular’s resolution algorithm starts at the consumer’s element injector and walks up — so a provider registered on the child component is the first match. If you want the parent’s instance instead, use inject(LoggerService, { skipSelf: true }). If you want to force the child-only lookup and fail if absent, use { self: true }.

Lesson 1.4 picks up TypeScript essentials. Module 1 closes after lesson 1.5.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *