Angular NotesRohit’s interview study guide
Chapter 09

Dependency Injection

How Angular creates and shares services — tokens, providers, injectors, provider strategies and the new @Service decorator.

13 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#The idea behind dependency injection

Dependency injection (DI) means a class doesn't build the things it needs — it asks for them, and something else decides what it gets. In Angular that "something else" is an injector: an object that knows how to create values for keys, creates each one at most once, and hands the same instance to everyone who asks it.

The mental model is a hotel front desk. A guest (your component) doesn't go into the kitchen and cook; they ring the desk and say "I need breakfast". The desk (the injector) knows which kitchen is on duty (the provider), sends the order there the first time, and keeps track of what's already been made. The guest never knows — or cares — whether breakfast came from the main kitchen or, in a test, from a vending machine. The general pattern is covered on the JavaScript site under Dependency injection; this chapter is about Angular's machinery for it.

Without DI — hard-wired

idea.ts
export class ConsoleLogger {
  log(message: string) {
    console.log(message);
  }
}

export class OrderServiceHardWired {
  private readonly logger = new ConsoleLogger(); // decided here, forever

  place(id: string) {
    this.logger.log(`order ${id} placed`);
  }
}

With DI — asks for it

idea.ts
import { Service, inject } from '@angular/core';
import { Logger } from './cart';

@Service()
export class OrderService {
  private readonly logger = inject(Logger);

  place(id: string) {
    this.logger.log(`order ${id} placed`);
  }
}
What’s happening
  1. OrderServiceHardWired calls new ConsoleLogger() itself. The choice of logger is baked into the class: to log somewhere else — or to check in a test what was logged — you have to edit OrderServiceHardWired.
  2. OrderService instead calls inject(Logger). That's a request: "give me whatever is registered under the key Logger". The class still names a type, but it no longer controls which object arrives or how it was built.
  3. @Service() registers OrderService itself with the application's root injector, so anything can inject(OrderService) and they all share one instance (next topic).
  4. The payoff is substitution. In a test you register a different value under the same key, and OrderService uses it without a single change — the spec below proves it.
AdvancedSpec: swapping in a fake Logger
idea.spec.ts
it('lets a test swap the dependency without touching OrderService', () => {
  const fakeLogger = { log: vi.fn() };
  TestBed.configureTestingModule({ providers: [{ provide: Logger, useValue: fakeLogger }] });
  TestBed.inject(OrderService).place('A-17');
  expect(fakeLogger.log).toHaveBeenCalledWith('order A-17 placed');
});
What’s happening
  1. providers: [{ provide: Logger, useValue: fakeLogger }] tells the test's root injector: "when someone asks for Logger, give them this object". It overrides the real Logger for this test only.
  2. TestBed.inject(OrderService) asks the injector for an OrderService. It doesn't have one yet, so it creates one — and while that constructor runs, the field initializer inject(Logger) asks the same injector for Logger and gets fakeLogger.
  3. place('A-17') calls fakeLogger.log('order A-17 placed'), which vi.fn() records. The assertion passes.
  4. Nothing in OrderService knew a test was running. That's the whole point of DI: the class declares what it needs; the configuration decides what it gets.
AdvancedWhere DI shows up in an app

Where DI shows up in an Angular app:

#Services with @Service and inject()

A service is just a class whose instance is meant to be shared: it holds state or logic that several components need, so it lives outside any one of them. In Angular 22 the standard way to write one is the new @Service() decorator plus the inject() function for its own dependencies. ng generate service in CLI 22.2 now emits @Service() by default (the generator has an --injectable flag that produces the old @Injectable({ providedIn: 'root' }) instead — I checked the schematic's template).

What @Service() does, exactly — from the ServiceDecorator declaration in @angular/core 22.2 and the compiler source:

  • @Service() "marks a class as a service and makes it automatically available in the dependency injection system". At runtime it compiles to a provider definition with providedIn: 'root' — I logged CartService.ɵprov.providedIn and got root. So @Service() is the same scope as @Injectable({ providedIn: 'root' }): one lazily-created, tree-shakable singleton for the whole app.
  • @Service({ autoProvided: false }) — "the service won't be exposed to the dependency injection system automatically. It is up to the user to expose it in a providers list." (see Opting out of root providing).
  • @Service({ factory: () => value }) — "creates a service that is automatically provided and uses the value returned from the factory function". The class becomes a typed token and the factory supplies what you get.
  • The compiler is stricter than with @Injectable: a @Service class cannot use constructor injection (error NG2028), cannot carry another Angular decorator (NG1006), must be called (@Service, without parentheses, is "@Service must be called"), and autoProvided must be a literal true/false.
cart.ts
import { Component, Service, computed, inject, signal } from '@angular/core';

@Service()
export class Logger {
  readonly lines = signal<string[]>([]);

  log(message: string) {
    this.lines.update((lines) => [...lines, message]);
  }
}

@Service()
export class CartService {
  private readonly logger = inject(Logger);
  private readonly items = signal<string[]>([]);

  readonly count = computed(() => this.items().length);

  add(item: string) {
    this.items.update((items) => [...items, item]);
    this.logger.log(`added ${item}`);
  }
}

@Component({
  selector: 'app-add-to-cart',
  template: `<button (click)="cart.add('book')">Add</button>`,
})
export class AddToCart {
  protected readonly cart = inject(CartService);
}

@Component({
  selector: 'app-cart-badge',
  template: `<span>{{ cart.count() }} items</span>`,
})
export class CartBadge {
  protected readonly cart = inject(CartService);
}
What’s happening
  1. Nothing is created when the app starts. @Service() only attaches a recipe (ɵprov, with providedIn: 'root' and a factory) to each class. Services are lazy: the first inject() creates them.
  2. Angular creates AddToCart. Its field initializer inject(CartService) asks the component's injector, which doesn't have it, so the request climbs to the root injector. The root injector sees CartService's recipe, runs it, and caches the instance.
  3. Creating CartService runs its field initializers: inject(Logger) makes the root injector create and cache a Logger too. Dependencies are resolved recursively, in the order the fields are declared.
  4. Angular creates CartBadge. Its inject(CartService) reaches the root injector again, which now returns the cached instance — the same object AddToCart holds.
  5. Clicking the button calls cart.add('book'): items goes [] → ['book'], so count goes 0 → 1, and because CartBadge's template reads cart.count(), the badge re-renders with "1 items". Two clicks give "2 items" and Logger.lines() is ['added book', 'added book'] — my spec asserts exactly that, plus TestBed.inject(CartService) === TestBed.inject(CartService).
  6. The components never talk to each other. They share state because DI handed them one service instance — this is the normal way to share state between components that aren't parent and child (see Sharing state with signal-based services).
Advanced@Service factory form for browser APIs

The factory form turns the class into a typed key whose value comes from a function. That's handy for wrapping something you don't own, like a browser API:

cart.ts
// The factory form: the class is only a typed token; the factory supplies the value.
@Service({ factory: () => inject(DOCUMENT).defaultView!.localStorage })
export abstract class BrowserStorage {
  abstract getItem(key: string): string | null;
  abstract setItem(key: string, value: string): void;
}
What’s happening
  1. BrowserStorage is abstract — nobody will ever new it. It exists to be a token (a lookup key) and to describe the shape callers can rely on: getItem and setItem.
  2. The first inject(BrowserStorage) runs the factory in the root injector. The factory is an injection context, so it can call inject(DOCUMENT) itself, then returns window.localStorage.
  3. In my spec, TestBed.inject(BrowserStorage) is window.localStorage itself (Object.prototype.toString gives [object Storage]), and storage instanceof BrowserStorage is false — the value is whatever the factory returned, not an instance of the class.
  4. The factory's return type is checked against the class. Returning 42 instead fails to compile with "TS1270: Decorator function return type 'abstract new (...args: any[]) => number' is not assignable to type 'void | typeof Wrong'… Type 'number' is not assignable to type 'Wrong'." Storage passes because it structurally has getItem and setItem.
  5. Tests (or a server-rendering setup without localStorage) can still replace it: { provide: BrowserStorage, useValue: fakeStorage }. The factory is only the default.
AdvancedPitfall: NG2028 with constructor parameters
Same instance?

AddToCart and CartBadge both inject(CartService). A third component is declared with providers: [CartService] and also injects it. How many CartService instances exist once all three are on screen?

Show answer

Two.

What’s happening
  1. @Service() makes CartService available from the root injector, which creates one instance on the first request and caches it. AddToCart and CartBadge have no providers of their own, so their requests climb to root and share that instance.
  2. The third component lists CartService in its own providers. That gives its element injector a separate recipe for the same token, and lookups stop at the first injector that has one — so it gets a second, private instance.
  3. My spec does the same with Logger: a component with providers: [Logger] gets an object that is not.toBe(TestBed.inject(Logger)). More in Component-level providers.

#Why @Injectable still exists

@Injectable is the decorator Angular has used for services since Angular 2, and it isn't deprecated in 22. @Service() covers the common case — root singleton, inject() style — but @Injectable still does several things @Service deliberately doesn't:

  • Constructor injection. Older code (and NgModule-era apps like techStatus on Angular 16) wires dependencies through constructor parameters. @Injectable is what lets the compiler generate a factory that reads those parameter types.
  • Not providing itself. @Injectable() with no providedIn only makes the class injectable; someone must still list it in providers. (@Service({ autoProvided: false }) now covers this too.)
  • Other scopes. providedIn: 'platform' (shared by every app on the page) and the deprecated providedIn: SomeNgModule / 'any'.
  • Inline provider options. @Injectable({ providedIn: 'root', useFactory: … }), useClass, useExisting and useValue in the decorator itself.
  • Everything already written. Angular's own classes and every library you depend on use it.
cart.ts
// The older equivalent of @Service(): still valid, and still needed for constructor injection.
@Injectable({ providedIn: 'root' })
export class LegacyUserApi {
  constructor(
    private http: HttpClient,
    private logger: Logger,
  ) {}

  load(id: number) {
    this.logger.log(`loading ${id}`);
    return this.http.get(`/api/users/${id}`);
  }
}
What’s happening
  1. providedIn: 'root' registers the class with the root injector — the same scope @Service() gives you. It's lazy and tree-shakable: if nothing ever injects LegacyUserApi, the bundler can drop it.
  2. The constructor declares two parameters. With @Injectable, the Angular compiler reads their types (HttpClient, Logger) and generates a factory roughly like () => new LegacyUserApi(inject(HttpClient), inject(Logger)).
  3. Those types work as tokens only because they are classes, which exist at runtime. A parameter typed string or an interface has nothing to look up (see the next topic).
  4. private http: HttpClient is TypeScript's parameter-property shorthand: it declares a field and assigns the argument to it. My spec checks TestBed.inject(LegacyUserApi) is an instance of LegacyUserApi, with HttpClient resolved from root (in Angular 22 HttpClient is provided in root by default; see Setting up HttpClient).
Advanced@Injectable() without providedIn
tokens.ts
@Injectable()
export class NewLogger {
  readonly id = ++created;
  log(msg: string) {
    return `[new #${this.id}] ${msg}`;
  }
}
What’s happening
  1. @Injectable() without providedIn only gives NewLogger a factory. It isn't registered anywhere.
  2. TestBed.inject(NewLogger) therefore throws "NG0201: No provider found for _NewLogger." (the leading underscore is how the test build renames decorated classes; your app's message shows the class name).
  3. After TestBed.configureTestingModule({ providers: [NewLogger] }) the same call succeeds. Listing the class in providers is what registers it — at the app level here, or in a component (see Component-level providers).
Advanced@Service vs @Injectable reference table
@Service()@Injectable({ providedIn: 'root' })@Injectable()
Provided in root automaticallyYesYesNo — needs providers
Constructor injectionNo (NG2028)YesYes
inject() in fieldsYesYesYes
Opt out of rootautoProvided: falseleave out providedInalready out
Value from a functionfactoryuseFactory—
Generated by ng g service (CLI 22.2)Default--injectable—
AdvancedChoosing @Service or @Injectable

#The @Inject decorator and inject()

There are two ways for a class to receive a dependency. The old way is constructor parameters: Angular reads each parameter's type and uses it as the token. The modern way is the inject() function, called in a field initializer or the constructor body, which takes the token as an ordinary argument.

The @Inject(token) parameter decorator exists because of a gap in the old way. TypeScript types disappear at runtime, so a parameter typed string gives Angular nothing to look up — and lots of useful dependencies (an API URL, a config object) aren't classes. @Inject(API_URL) says "ignore this parameter's type; use this token". inject() doesn't have the gap at all, because you always pass the token explicitly.

tokens.ts
export const API_URL = new InjectionToken<string>('API_URL');

// Old style: constructor parameters + @Inject for non-class tokens.
@Injectable({ providedIn: 'root' })
export class OldStyleClient {
  constructor(
    @Inject(API_URL) readonly baseUrl: string,
    private logger: Logger, // a class type is its own token — no @Inject needed
  ) {}
}

// Modern style: inject() takes the token directly.
@Service()
export class NewStyleClient {
  readonly baseUrl = inject(API_URL);
  private readonly logger = inject(Logger);
}
What’s happening
  1. API_URL is an InjectionToken<string> — a unique runtime object that serves as a key, with the <string> telling TypeScript what the value's type will be (see InjectionToken).
  2. In OldStyleClient, the compiler builds a factory from the parameter list. For baseUrl, the type string is useless as a key, so @Inject(API_URL) supplies the key instead. For logger, the type Logger is a class, so the type itself is the key.
  3. In NewStyleClient there's no parameter list to analyse. inject(API_URL) returns a string (the token's type parameter flows through), and inject(Logger) returns a Logger.
  4. My spec provides { provide: API_URL, useValue: 'https://api.test' } and both classes end up with baseUrl === 'https://api.test' — the two styles resolve the exact same tokens from the exact same injector.
  5. If you leave out @Inject on the string parameter, the build fails: "NG2003: No suitable injection token for parameter 'baseUrl' of class 'NoToken'. Consider using the @Inject decorator to specify an injection token."
AdvancedWhy inject() won over @Inject

Why inject() won:

  • No decorator metadata. It works without emitDecoratorMetadata and isn't affected by how TypeScript compiles parameter properties.
  • Inheritance is painless. A subclass doesn't have to repeat every parent constructor parameter and call super(a, b, c).
  • Reusable helpers. A plain function can call inject() (e.g. injectLogger('checkout')), as long as it's called from an injection context (see The injection context).
  • Options are explicit. inject(X, { optional: true, self: true }) instead of stacking @Optional() @Self() @Inject(X).
  • Types flow. inject(API_URL) is typed string from the token; @Inject(API_URL) url: number would compile and lie.
AdvancedNaming confusion: two similar decorators

#Tokens, providers, injectors and instances

Four words get used loosely, and interviews love to check you can separate them:

  • Token — the key you ask for. A class (CartService), an InjectionToken (API_URL), or rarely a string. It identifies what you want, not how to make it.
  • Provider — the recipe registered under a token: "for Logger, use this class / value / factory / other token". @Service() and providedIn: 'root' are providers declared on the class itself; providers: [...] arrays declare them from outside.
  • Injector — the container that holds providers, runs a recipe the first time a token is requested, and caches the result. Injectors form a tree.
  • Instance — the value the injector produced and cached. One per provider per injector.

The analogy that sticks: a token is the name on a coat-check ticket, a provider is the instruction "if nobody's collected it yet, fetch it from rack B", an injector is the coat-check desk, and the instance is the coat. Two people holding tickets with the same name at the same desk get the same coat; a different desk (a component's own injector) can hold a different coat under the same name.

tokens.spec.ts
it('without multi, the last provider wins', () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: API_URL, useValue: 'first' },
      { provide: API_URL, useValue: 'second' },
    ],
  });
  expect(TestBed.inject(API_URL)).toBe('second');
});
What’s happening
  1. { provide: API_URL, useValue: 'first' } is a provider object: provide is the token, useValue: 'first' is the recipe.
  2. The second entry registers another recipe for the same token in the same injector. An injector keeps one recipe per token, so the later entry replaces the earlier one.
  3. TestBed.inject(API_URL) asks the test's root injector, which runs the surviving recipe and returns the instance 'second'.
  4. This "last one wins" rule is how overrides work: a test or a feature can re-provide a token that a library already provided. (To collect several values instead, add multi: true — see InjectionToken.)

#Provider strategies: useClass, useValue, useFactory, useExisting

A provider object always has provide (the token) plus one recipe. There are four recipes:

StrategyRecipeTypical use
useClassnew this class (resolving its own dependencies)Swap an implementation: { provide: Logger, useClass: RemoteLogger }
useValueReturn this exact valueConfig objects, constants, simple fakes in tests
useFactoryCall this function and use its return valueDecide at runtime, or build something that needs arguments
useExistingReturn whatever another token resolves toAn alias: two tokens, one instance

The shorthand providers: [NewLogger] means { provide: NewLogger, useClass: NewLogger }.

The one interviewers probe is useClass vs useExisting, because they look alike and behave very differently. My spec proves it with a counter that numbers every NewLogger the injector creates:

tokens.ts
let created = 0;

@Injectable()
export class NewLogger {
  readonly id = ++created;
  log(msg: string) {
    return `[new #${this.id}] ${msg}`;
  }
}

// The class the rest of the app already injects everywhere.
@Injectable()
export class OldLogger {
  log(msg: string) {
    return `[old] ${msg}`;
  }
}
AdvancedSpec: useExisting vs useClass instances
tokens.spec.ts
it('useExisting: two tokens, ONE instance', () => {
  TestBed.configureTestingModule({
    providers: [NewLogger, { provide: OldLogger, useExisting: NewLogger }],
  });
  const viaOld = TestBed.inject(OldLogger);
  const viaNew = TestBed.inject(NewLogger);
  expect(viaOld).toBe(viaNew);
  expect(viaOld.log('hi')).toBe('[new #1] hi');
});

it('useClass: two tokens, TWO instances', () => {
  TestBed.configureTestingModule({
    providers: [NewLogger, { provide: OldLogger, useClass: NewLogger }],
  });
  const viaOld = TestBed.inject(OldLogger);
  const viaNew = TestBed.inject(NewLogger);
  expect(viaOld).not.toBe(viaNew);
  expect(viaOld.log('hi')).toBe('[new #1] hi');
  expect(viaNew.log('hi')).toBe('[new #2] hi');
  expect(viaOld instanceof NewLogger).toBe(true);
});
What’s happening
  1. Both tests register NewLogger under its own token, and something under the OldLogger token. Think of the injector as a table with two rows, OldLogger → ? and NewLogger → new NewLogger().
  2. useExisting: the OldLogger row says "go and look up NewLogger". inject(OldLogger) follows the alias to the NewLogger row, which creates the instance (id 1) and caches it. inject(NewLogger) hits the same row and gets the cached object. Same object, toBe passes, and only one NewLogger was ever constructed.
  3. useClass: the OldLogger row says "construct a NewLogger for this row". inject(OldLogger) runs that and caches instance id 1 under OldLogger. inject(NewLogger) has its own row and its own cache, so it constructs instance id 2. Two objects of the same class.
  4. Why it matters: if NewLogger holds state (a buffer, a signal, a WebSocket), useClass silently splits it in two — half the app writes to one copy, half to the other. Use useExisting when the goal is "old token, same service" (migrations, exposing a service under an abstract token); use useClass when you really want a separate implementation or instance.
AdvanceduseFactory with inject() and deps

useFactory runs a function in an injection context, so the function can inject() what it needs. The older form lists the dependencies in a deps array that's passed in as arguments:

tokens.spec.ts
it('useFactory picks an implementation at runtime, once per injector', () => {
  let calls = 0;
  TestBed.configureTestingModule({
    providers: [
      { provide: LOCALE, useValue: 'fr' },
      {
        provide: Greeter,
        useFactory: () => {
          calls++;
          return inject(LOCALE) === 'fr' ? new FrenchGreeter() : new EnglishGreeter();
        },
      },
    ],
  });
  expect(TestBed.inject(Greeter).greet('Rohit')).toBe('Bonjour, Rohit');
  TestBed.inject(Greeter);
  expect(calls).toBe(1);
});

it('useFactory with the older deps array', () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: LOCALE, useValue: 'en' },
      {
        provide: Greeter,
        useFactory: (locale: string) => (locale === 'fr' ? new FrenchGreeter() : new EnglishGreeter()),
        deps: [LOCALE],
      },
    ],
  });
  expect(TestBed.inject(Greeter).greet('Rohit')).toBe('Hello, Rohit');
});
What’s happening
  1. Greeter is an abstract class used as the token; EnglishGreeter and FrenchGreeter extend it. LOCALE is an InjectionToken<string> provided with useValue.
  2. In the first test, the first TestBed.inject(Greeter) runs the factory: calls goes 0 → 1, inject(LOCALE) returns 'fr', and a FrenchGreeter is returned and cached. greet('Rohit') gives 'Bonjour, Rohit'.
  3. The second TestBed.inject(Greeter) returns the cached instance without calling the factory — calls is still 1. A factory provider is a once-per-injector recipe, not a "call me every time" function.
  4. In the second test, deps: [LOCALE] tells Angular to resolve LOCALE and pass it as the factory's first argument. 'en' arrives as locale, so an EnglishGreeter is built and greet gives 'Hello, Rohit'.
  5. Both forms are valid in Angular 22. The inject() form is easier to read and type-check; deps is what you'll see in older code and libraries.
AdvancedPitfall: shared instances in useValue
Predict the ids
TypeScript
export const LEGACY = new InjectionToken<OldLogger>('LEGACY');

providers: [
  NewLogger,
  { provide: OldLogger, useClass: NewLogger },
  { provide: LEGACY, useExisting: OldLogger },
]
// then, in this order (the counter starts at 0):
inject(NewLogger).id;
inject(LEGACY).id;
inject(OldLogger).id;
Show answer

1, 2, 2 — my spec asserts exactly [1, 2, 2].

What’s happening
  1. inject(NewLogger) hits the NewLogger row first: the counter goes 0 → 1, so its id is 1.
  2. inject(LEGACY) follows the useExisting alias to OldLogger. The OldLogger row is useClass: NewLogger and hasn't run yet, so it constructs a new NewLogger: counter 1 → 2, id 2, cached under OldLogger.
  3. inject(OldLogger) finds its row already has a cached instance and returns it — id 2. The alias added no instance of its own; useClass did.

#InjectionToken

A token has to exist at runtime, because the injector looks providers up by object identity. Classes exist at runtime, so they work as their own tokens. But many things you want to inject aren't classes: a URL string, a config object described by an interface, a function, a Window. An interface vanishes when TypeScript compiles, so there's nothing to look up:

TypeScript
export interface AppConfig { apiUrl: string }

@Service()
export class UsesInterface {
  config = inject(AppConfig);
  // ✘ TS2693: 'AppConfig' only refers to a type, but is being used as a value here.
}
AdvancedDefining and providing InjectionTokens

InjectionToken<T> fixes that: it's a small, unique object that stands in for the value, and its type parameter T carries the static type so inject() returns a T. Its description string ('APP_CONFIG') is only for error messages — two tokens with the same description are still two different keys.

tokens.ts
export interface AppConfig {
  apiUrl: string;
  retries: number;
}

// An interface has no runtime value, so it needs a token to stand in for it.
export const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');

// A token can carry its own default — no provider needed anywhere.
export const WINDOW = new InjectionToken<Window>('WINDOW', {
  providedIn: 'root',
  factory: () => inject(DOCUMENT).defaultView!,
});

// providedIn left out: does it still provide itself?
export const NOW = new InjectionToken<() => number>('NOW', {
  factory: () => () => Date.now(),
});
What’s happening
  1. APP_CONFIG has no default. Until something provides it, TestBed.inject(APP_CONFIG) throws "NG0201: No provider found for InjectionToken APP_CONFIG." With { provide: APP_CONFIG, useValue: { apiUrl: '/api', retries: 2 } } in the providers, it returns that object, typed AppConfig.
  2. WINDOW is the "token that provides itself" from my notes: providedIn: 'root' plus a factory means the root injector can create it on demand with no providers entry. The factory runs in an injection context, so inject(DOCUMENT) works inside it. My spec gets window back.
  3. NOW leaves providedIn out. I logged NOW.ɵprov.providedIn and got root — in Angular 22 a token with a factory defaults to root. (The .d.ts marks providedIn: NgModule and 'any' as deprecated.)
  4. A self-providing token is still overridable: { provide: NOW, useValue: () => 0 } makes TestBed.inject(NOW)() return 0. That's the same pattern as the @Service factory form — a sensible default plus an escape hatch for tests.
  5. Compare with a @Service({ factory }) abstract class: both give you a typed key with a default. Use a token for non-class values (strings, functions, config); use the abstract-class form when you want a class name to depend on, with abstract members documenting the contract.
AdvancedMulti providers with multi: true

Multi providers are the other reason tokens exist. With multi: true, each provider adds to an array instead of replacing the previous one. Angular uses this itself — HTTP interceptors registered the old way (HTTP_INTERCEPTORS) and app initializers are multi tokens.

tokens.spec.ts
it('multi providers collect values', () => {
  TestBed.configureTestingModule({
    providers: [
      { provide: VALIDATORS, useValue: 'required', multi: true },
      { provide: VALIDATORS, useValue: 'email', multi: true },
    ],
  });
  expect(TestBed.inject(VALIDATORS)).toEqual(['required', 'email']);
});
What’s happening
  1. VALIDATORS is declared as InjectionToken<string[]>, because with multi the injected value is an array.
  2. Each multi: true provider contributes one entry, in registration order: ['required'], then ['required', 'email'].
  3. Without multi, the second provider would replace the first and you'd get 'email' — the "last one wins" rule from the previous topic.

#The injection context

inject() is not magic global lookup. It works only while Angular is in the middle of creating something through DI — that's the injection context. Angular sets a "current injector" before it runs a constructor or factory and clears it afterwards; inject() simply reads that current injector. Call it at any other moment and there's no injector to read.

Places that are an injection context:

  • A class's field initializers and constructor body, when DI creates the class (components, directives, pipes, services).
  • Factory functions of providers (useFactory, @Service({ factory }), InjectionToken factories).
  • Functional guards, resolvers and HTTP interceptors — the router and HttpClient run them in one.
  • Any function passed to runInInjectionContext(injector, fn).

Places that are not: event handlers, lifecycle hooks such as ngOnInit, setTimeout/subscribe callbacks, async code after an await, and module top level.

scopes.ts
@Component({
  selector: 'app-profile',
  template: `<button (click)="onSave()">Save</button>`,
})
export class Profile {
  private readonly logger = inject(Logger); // ✅ field initializer
  private readonly injector = inject(Injector); // ✅ capture the injector for later

  constructor() {
    inject(Logger).log('constructor ok'); // ✅ constructor
  }

  onSave() {
    inject(Logger).log('save'); // ❌ NG0203 — event handlers run outside the injection context
  }

  onSaveFixed() {
    runInInjectionContext(this.injector, () => inject(Logger).log('save via runInInjectionContext'));
  }
}
What’s happening
  1. TestBed.createComponent(Profile) makes Angular set the current injector to the component's element injector, then run field initializers and the constructor. Both inject(Logger) calls and inject(Injector) succeed; Logger.lines() becomes ['constructor ok'].
  2. When construction finishes, Angular restores the previous (empty) context. From now on, code in this class runs "outside".
  3. Calling onSave() later — exactly what a click does — runs inject(Logger) with no current injector, and it throws: "NG0203: The _Logger token injection failed. inject() function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext."
  4. onSaveFixed() uses the Injector captured during construction. runInInjectionContext(this.injector, fn) sets it as the current injector, runs fn, and restores the old one, so inject(Logger) works. My spec ends with ['constructor ok', 'save via runInInjectionContext'].
  5. The usual fix is simpler than runInInjectionContext: inject in a field (like this.logger) and use the field in the handler. Reach for runInInjectionContext only when you must call an API that itself needs a context (toSignal, effect, httpResource) after construction.
AdvancedHelper functions that call inject()

The same rule applies to every API built on inject(). toSignal() checks for a context up front and throws its own NG0203 — "toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with runInInjectionContext." — unless you pass { injector } (see Where toSignal() can run). effect() behaves the same way (see effect() and when not to use it).

Helper functions that call inject() are fine, because the context belongs to the call, not to where the function is written:

scopes.ts
// Helper functions that call inject() are fine — as long as THEY are called in an injection context.
export function injectLogger(prefix: string) {
  const logger = inject(Logger);
  return (msg: string) => logger.log(`${prefix}: ${msg}`);
}

@Component({ selector: 'app-uses-helper', template: `` })
export class UsesHelper {
  readonly log = injectLogger('helper'); // ✅ field initializer
}
What’s happening
  1. injectLogger is an ordinary function. It isn't a context on its own — it borrows whichever context it's called in.
  2. UsesHelper's field initializer calls it during construction, so inject(Logger) inside it succeeds and the returned closure captures logger (see the JS site's closures).
  3. Later, fixture.componentInstance.log('hi') runs the closure — no inject() happens at that point, it just uses the captured logger. My spec sees ['helper: hi'].
  4. This "inject now, use later" shape is how injectXxx() helper functions in libraries work, and why their docs say "call it in a constructor or field initializer".
AdvancedPitfall: injecting after an async boundary

#Component-level providers

A provider listed in a component's providers array lives in that component's element injector. Every instance of the component gets its own injector, so every instance gets its own instance of the service, created with the component and destroyed with it. The component's children (its template, and their templates) see that instance, because lookups climb from child to parent and stop at the first match. Siblings and the rest of the app don't.

This is how you give each widget its own state — each counter, each wizard, each row editor — instead of one global singleton. It's also how you override a root service for one subtree.

scopes.ts
@Service({ autoProvided: false })
export class CounterStore implements OnDestroy {
  readonly id = ++storeCount;
  readonly count = signal(0);
  static destroyed: number[] = [];

  increment() {
    this.count.update((c) => c + 1);
  }

  ngOnDestroy() {
    CounterStore.destroyed.push(this.id);
  }
}

@Component({
  selector: 'app-counter-label',
  template: `<i>store #{{ store.id }} = {{ store.count() }}</i>`,
})
export class CounterLabel {
  protected readonly store = inject(CounterStore); // resolved from the nearest ancestor that provides it
}

@Component({
  selector: 'app-counter',
  imports: [CounterLabel],
  providers: [CounterStore], // each <app-counter> gets its own CounterStore
  template: `<button (click)="store.increment()">+1</button> <app-counter-label />`,
})
export class Counter {
  protected readonly store = inject(CounterStore);
}

@Component({
  selector: 'app-counters',
  imports: [Counter],
  template: `
    <app-counter />
    @if (showSecond()) {
      <app-counter />
    }
  `,
})
export class Counters {
  readonly showSecond = signal(true);
}
What’s happening
  1. Counters renders two <app-counter> elements. For each one, Angular creates an element injector holding the CounterStore provider from providers: [CounterStore].
  2. The first Counter runs inject(CounterStore): its own element injector has the provider, so it creates store #1. The second Counter does the same in its own injector and gets store #2.
  3. Each Counter renders an <app-counter-label>. The label has no providers, so its inject(CounterStore) climbs to the nearest ancestor that has one — its own Counter — and gets that counter's store. Label 1 sees #1, label 2 sees #2.
  4. Clicking the first button twice takes store #1 from 0 → 1 → 2. My spec reads both labels and gets ['store #1 = 2', 'store #2 = 0']: the second counter is untouched.
  5. Setting showSecond to false removes the second Counter, which destroys its element injector, which calls ngOnDestroy on store #2. CounterStore.destroyed becomes [2]. A component-scoped service lives and dies with its component — a good place for per-widget subscriptions.
  6. autoProvided: false makes sure nobody accidentally gets a root-level CounterStore: without a Counter ancestor, inject(CounterStore) throws NG0201 instead of silently sharing one global store (next topic).
AdvancedOverriding a root service for a subtree

Overriding a root service for one subtree works the same way:

scopes.ts
// Overriding a root service for one subtree.
@Component({
  selector: 'app-isolated',
  providers: [Logger], // a SECOND Logger, separate from the root one
  template: ``,
})
export class Isolated {
  readonly logger = inject(Logger);
}
What’s happening
  1. Logger is a @Service(), so the root injector already has one.
  2. providers: [Logger] puts another recipe for the same token on Isolated's element injector. inject(Logger) checks that injector first, finds it, and creates a new instance there.
  3. My spec asserts fixture.componentInstance.logger is not TestBed.inject(Logger). Everything inside <app-isolated> logs to the private copy; the rest of the app keeps using the root one.
  4. This is the classic interview trap: "I added CartService to a component's providers and now the cart is empty in that part of the page." The component got a fresh instance. Remove the entry to go back to the shared one.
AdvancedviewProviders and projected content

#Opting out of root providing: autoProvided false

@Service() provides itself in root. That's what you want for app-wide singletons, but wrong for state that should only exist inside a scope — a per-component store, a per-route wizard, a per-feature cache. If such a class were root-provided, forgetting to add it to providers wouldn't fail: some component would silently get the global instance and share state with every other one.

@Service({ autoProvided: false }) is Angular 22's way to say "this is a service, but I'll decide where it's provided". It compiles to providedIn: null — it's injectable, but no injector has it until you list it. It's the @Service equivalent of @Injectable() without providedIn.

scopes.spec.ts
it('autoProvided:false is not available from the root injector', () => {
  let message = '';
  try {
    TestBed.inject(CounterStore);
  } catch (e) {
    message = (e as Error).message;
  }
  expect(message).toContain('NG0201');
});

it('autoProvided:false can be provided at the application level too', () => {
  TestBed.configureTestingModule({ providers: [CounterStore] });
  expect(TestBed.inject(CounterStore)).toBe(TestBed.inject(CounterStore));
});
What’s happening
  1. CounterStore (from Component-level providers) is declared with @Service({ autoProvided: false }). Its definition has no providedIn, so the root injector has no recipe for it.
  2. TestBed.inject(CounterStore) asks the root injector, which can't find a provider, and there is nothing above it to ask. It throws "NG0201: No provider found for _CounterStore." That loud failure is the point: you can't share it by accident.
  3. In the second test, providers: [CounterStore] registers it in the test's environment injector. Now it behaves like a singleton for that injector: two inject calls return the same object.
  4. So autoProvided: false doesn't mean "component-only" — it means "only where I list it": a component's providers, a route's providers, or bootstrapApplication's providers.
AdvancedWhere to provide an opted-out service

Where you'd list it:

  • A component — providers: [CounterStore], one per component instance (most common).
  • A route — { path: 'checkout', providers: [CheckoutState], loadComponent: … }, one per route's environment injector, shared by everything under that route.
  • The app — appConfig.providers, if you want a singleton but want it to be explicit (see appConfig and application providers).
AdvancedRule of thumb: shared vs per-instance copies

#Optional dependencies

Sometimes a dependency is nice-to-have: an analytics client that only exists in production, a parent form a field can work without, a plugin token a library lets you fill in. By default, a missing provider is an error (NG0201). inject(token, { optional: true }) turns "missing" into null, and its return type becomes T | null so TypeScript makes you handle it. The old equivalent is the @Optional() parameter decorator.

scopes.ts
export interface Analytics {
  send(event: string): void;
}
export const ANALYTICS = new InjectionToken<Analytics>('ANALYTICS');

@Service()
export class Tracker {
  private readonly analytics = inject(ANALYTICS, { optional: true }); // Analytics | null
  readonly sent: string[] = [];

  track(event: string) {
    if (this.analytics) {
      this.analytics.send(event);
    } else {
      this.sent.push(`(dropped) ${event}`);
    }
  }
}
What’s happening
  1. ANALYTICS is a token with no default factory, so it only exists if someone provides it.
  2. In the first spec nobody does. inject(ANALYTICS, { optional: true }) walks the whole injector chain, finds nothing, and returns null instead of throwing. track('signup') takes the else branch: sent becomes ['(dropped) signup'].
  3. In the second spec the test provides { provide: ANALYTICS, useValue: { send: (e) => sent.push(e) } }. Now analytics is that object and track('signup') forwards to it: ['signup'].
  4. The type Analytics | null is what keeps this safe — calling this.analytics.send(...) without the if would be a compile error under strict mode.
AdvancedEdge case: optional with root services

#Dependency inversion for testing

The dependency inversion principle says high-level code should depend on an abstraction, not on a concrete low-level class. In Angular terms: Checkout should ask for "a payment gateway", not for "the HTTP-calling Stripe gateway". DI then plugs in the real one in the app and a fake one in tests, and Checkout never knows the difference.

The neat Angular 22 version uses an abstract class as the token and the @Service factory form to make the real implementation the default:

payments.ts
export interface ChargeResult {
  ok: boolean;
  id: string;
}

// The abstraction. The factory makes the real gateway the default,
// so the app needs no extra provider — but tests can swap it.
@Service({ factory: () => inject(HttpPaymentGateway) })
export abstract class PaymentGateway {
  abstract charge(cents: number): Promise<ChargeResult>;
}

@Service()
export class HttpPaymentGateway extends PaymentGateway {
  private readonly http = inject(HttpClient);

  charge(cents: number) {
    return firstValueFrom(this.http.post<ChargeResult>('/api/charges', { cents }));
  }
}

// High-level code depends only on the abstraction.
@Service()
export class Checkout {
  private readonly gateway = inject(PaymentGateway);
  readonly status = signal<'idle' | 'paid' | 'declined'>('idle');

  async pay(cents: number) {
    if (cents <= 0) throw new Error('amount must be positive');
    const result = await this.gateway.charge(cents);
    this.status.set(result.ok ? 'paid' : 'declined');
    return result.id;
  }
}
What’s happening
  1. PaymentGateway is the abstraction: an abstract class (so it exists at runtime and can be a token) with one abstract method. See the TypeScript site on abstract classes.
  2. Its @Service({ factory: () => inject(HttpPaymentGateway) }) says "by default, PaymentGateway resolves to the root HttpPaymentGateway". The factory is lazy, so referring to a class declared further down the file is fine.
  3. Checkout only ever mentions PaymentGateway. In the app, inject(PaymentGateway) runs the factory, which creates the HTTP gateway. My spec checks TestBed.inject(PaymentGateway) is TestBed.inject(HttpPaymentGateway) — one instance, two tokens, just like useExisting.
  4. With provideHttpClientTesting(), calling pay(1999) produces a POST /api/charges with body { cents: 1999 }; flushing { ok: true, id: 'ch_real' } resolves pay to 'ch_real'.
  5. The business logic (validation, mapping ok to a status) is the part worth testing, and it doesn't need HTTP at all — which the next spec shows.
AdvancedSpec: checkout with a fake gateway
payments.spec.ts
class FakeGateway extends PaymentGateway {
  calls: number[] = [];
  next: ChargeResult = { ok: true, id: 'ch_fake' };
  async charge(cents: number) {
    this.calls.push(cents);
    return this.next;
  }
}

it('uses the fake gateway — no HTTP at all', async () => {
  TestBed.configureTestingModule({
    providers: [{ provide: PaymentGateway, useValue: new FakeGateway() }],
  });
  const checkout = TestBed.inject(Checkout);
  const fake = TestBed.inject(PaymentGateway) as FakeGateway;

  expect(await checkout.pay(1999)).toBe('ch_fake');
  expect(checkout.status()).toBe('paid');
  expect(fake.calls).toEqual([1999]);

  fake.next = { ok: false, id: 'ch_declined' };
  await checkout.pay(500);
  expect(checkout.status()).toBe('declined');
});
What’s happening
  1. FakeGateway implements the same contract with an in-memory script: it records each amount in calls and returns whatever next holds.
  2. { provide: PaymentGateway, useValue: new FakeGateway() } replaces the PaymentGateway token for this test. The @Service factory never runs, so HttpPaymentGateway and HttpClient are never created.
  3. TestBed.inject(Checkout) creates Checkout, whose inject(PaymentGateway) gets the fake. pay(1999) awaits the fake's promise, sets status to 'paid', returns 'ch_fake', and calls is [1999].
  4. Changing fake.next to { ok: false, … } drives the other branch: status becomes 'declined'. You can test every branch of Checkout in milliseconds, with no network stubs.
  5. This is the practical meaning of "dependency inversion for testing": the test controls the abstraction, so it controls every input the logic sees.
AdvancedTesting pitfall: useClass returns the real service
AdvancedFurther reading: mocks, spies and fakes

For more on fakes, spies and vi.fn(), see Mocks, spies and fakes.

#Hierarchical injectors

Added

Angular doesn't have one injector; it has a tree of them, in two layers:

  • Environment injectors — the root injector (from bootstrapApplication providers and everything providedIn: 'root'), plus child environment injectors created for lazy-loaded routes with providers. Above root sits the platform injector, and above that a null injector that throws NG0201.
  • Element injectors — one per element that has a component or directive with providers/viewProviders. They mirror the template tree: a component's element injector is the parent of the element injectors inside its template.

When something calls inject(token), Angular walks up the element injectors first — from the requesting element, to its parent element, through each component's host, up to the root component — and only if none of them has a provider does it switch to the environment injectors: the nearest environment injector, then its parents, root, platform, null. The first injector that has a provider wins, creates (or reuses) the instance, and the walk stops.

① Element injectors component and directive providers <app-theme-probe> calls inject() <app-panel> providers <app-page> providers root component not found ② Environment injectors app, route and providedIn: 'root' route injector (lazy routes) root (appConfig + providedIn root) platform null injector → NG0201
The first injector on the path that has a provider for the token creates (or reuses) the instance, and the walk stops.

inject() takes options that change where the walk starts and stops:

OptionMeaning
self: trueOnly look at the requesting element's own injector
skipSelf: trueStart at the parent, skipping the requesting element's own injector
host: trueStop at the host component of the template this code is declared in
optional: trueReturn null instead of throwing when the walk finds nothing
hierarchy.ts
export const THEME = new InjectionToken<string>('THEME', { factory: () => 'root-default' });

@Component({
  selector: 'app-theme-probe',
  template: `{{ theme }} | parent={{ fromParent ?? 'null' }} | self={{ own ?? 'null' }}`,
})
export class ThemeProbe {
  readonly theme = inject(THEME); // normal walk: element injectors, then environment injectors
  readonly fromParent = inject(THEME, { skipSelf: true, optional: true }); // start one level up
  readonly own = inject(THEME, { self: true, optional: true }); // only this element
}

@Component({
  selector: 'app-panel',
  imports: [ThemeProbe, HostTheme],
  providers: [{ provide: THEME, useValue: 'panel' }],
  template: `<p class="inside" appHostTheme><app-theme-probe /></p>`,
})
export class Panel {}

@Component({
  selector: 'app-page',
  imports: [Panel, ViewPanel, ThemeProbe, HostTheme],
  template: `
    <div class="outside" appHostTheme><app-theme-probe /></div>
    <app-panel />
    <app-view-panel />
  `,
})
export class Page {}
What’s happening
  1. THEME has a root default, 'root-default'. Panel overrides it for its subtree with providers: [{ provide: THEME, useValue: 'panel' }].
  2. The probe outside the panel (.outside): no element on its path provides THEME, so the walk falls through to the environment and the token's factory gives 'root-default'. skipSelf also ends up at root. self checks only the probe's own element, which has no provider, so it's null. Rendered: root-default | parent=root-default | self=null.
  3. The probe inside the panel (.inside): the walk reaches <app-panel>'s element injector and stops there: 'panel'. skipSelf starts one level up from the probe — still finds the panel. self is still null. Rendered: panel | parent=panel | self=null.
  4. In the second spec, the test's environment provides { provide: THEME, useValue: 'app' }. The outside probe now gets 'app' (an explicit provider beats the token's built-in factory), but the inside probe still gets 'panel' — element injectors are checked before any environment injector.
  5. This is what "nearest provider wins" means in practice: the same inject(THEME) line returns different values depending on where in the tree the component is.
Advancedself and skipSelf in a nested probe

self and skipSelf matter most when a component provides a token and wants its parent's version of it — e.g. a nested menu that registers itself and also needs its parent menu:

hierarchy.ts
// A probe that provides THEME on its own element.
@Component({
  selector: 'app-self-probe',
  providers: [{ provide: THEME, useValue: 'self' }],
  template: `{{ theme }} | parent={{ fromParent }} | self={{ own }}`,
})
export class SelfProbe {
  readonly theme = inject(THEME);
  readonly fromParent = inject(THEME, { skipSelf: true });
  readonly own = inject(THEME, { self: true });
}

@Component({
  selector: 'app-self-page',
  imports: [SelfProbe],
  providers: [{ provide: THEME, useValue: 'page' }],
  template: `<app-self-probe />`,
})
export class SelfPage {}
What’s happening
  1. SelfProbe provides THEME = 'self' on its own element, and sits inside SelfPage, which provides THEME = 'page'.
  2. Plain inject(THEME) starts at the probe's own element injector, which has a provider: 'self'.
  3. skipSelf: true skips that injector and starts at the parent, <app-self-page>: 'page'. This is how a child gets "the version my parent sees" even though it shadows the token itself.
  4. self: true only checks the probe's own injector: 'self'. Rendered: self | parent=page | self=self — exactly what my spec asserts.
Advancedhost: true and where the lookup stops

host: true is the subtle one. It stops the walk at the host element of the component whose template the requesting code is written in, and it never falls back to environment injectors. I tested it with a directive:

hierarchy.ts
// host: true — search stops at the host element of the component whose template this directive is in.
@Directive({ selector: '[appHostTheme]', host: { '[attr.data-host-theme]': 'theme ?? "null"' } })
export class HostTheme {
  readonly theme = inject(THEME, { host: true, optional: true });
}

@Component({
  selector: 'app-view-panel',
  imports: [HostTheme],
  viewProviders: [{ provide: THEME, useValue: 'view-panel' }],
  template: `<p class="view-inside" appHostTheme></p>`,
})
export class ViewPanel {}
What’s happening
  1. appHostTheme is used three times: in Page's template (.outside), in Panel's template (.inside) and in ViewPanel's template (.view-inside). It writes what it resolved into a data-host-theme attribute.
  2. .outside is in Page's template, so the boundary is <app-page>. Nothing up to there provides THEME, and host doesn't continue to the environment — so even though the token has a root default, the result is null.
  3. .inside is in Panel's template. I expected 'panel', but got null: a host component's providers are not visible to a host: true lookup from inside its own view.
  4. .view-inside is in ViewPanel's template, and ViewPanel uses viewProviders. Now the lookup finds it: 'view-panel'. My spec asserts ['null', 'null', 'view-panel'].
  5. Takeaway: host is a tool for directives that must only cooperate with the component they're used in (for example a form control looking for its form). If you rely on it, provide with viewProviders. In ordinary app code you rarely need it.
Which value?

THEME has a root factory returning 'root-default'. appConfig provides { provide: THEME, useValue: 'app' }. A <app-card> component with providers: [{ provide: THEME, useValue: 'card' }] contains <app-badge>, which calls inject(THEME, { skipSelf: true }). What does the badge get?

Show answer

'card'.

What’s happening
  1. skipSelf skips the badge's own element injector — the badge has no providers anyway, so that changes nothing here.
  2. The walk continues to the parent element, <app-card>, whose element injector provides 'card'. It stops there.
  3. The environment's 'app' and the token default 'root-default' are never consulted, because element injectors are always checked first and one of them matched.
  4. If <app-badge> itself provided THEME, skipSelf is what would still let it read 'card'. (My spec renders this exact setup and the badge shows card.)

Built from Rohit’s “Angular interview prep” doc. Examples target Angular 22 and are compiled and tested with the Angular CLI and Vitest.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.