skip to content

In Angular, how do you inject a service into a custom pipe, which injector resolves it, and how many pipe instances does a template create?

level: middleimportance: nice to knowfreq 25%

answer

  1. pipes take part in DI
  2. resolved like a directive on the element
  3. no view providers for pipes
  4. one instance per usage

basics

~10 s

Call inject() in the pipe class; it resolves from the element injector where the pipe is used, then environment injectors, without the host's viewProviders. Each usage in each view gets its own pipe instance.

solid answer

~40 s

A pipe can call `inject()` in a field initializer or take constructor parameters. Angular resolves them as if the pipe were a directive on the element where it is used: element injectors up the tree, then environment injectors, but explicitly without the host component's `viewProviders`. `@Pipe` has no `providers` option. Angular creates one pipe instance per usage in a view, so two bindings in one template are two instances and a pipe in an `@for` row has one per row; instances die with their view and can implement `ngOnDestroy`. Shared state belongs in an injected service, and the reverse direction, injecting a pipe class into a service, is discouraged: extract a plain function instead.

code

ts · 18 lines
ts
import { Injectable, Pipe, PipeTransform, inject } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StatusLabels {
  private readonly labels: Record<string, string> = { A: 'Active', S: 'Suspended' };
  lookup(code: string): string {
    return this.labels[code] ?? code;
  }
}

@Pipe({ name: 'statusLabel' })
export class StatusLabelPipe implements PipeTransform {
  private readonly labels = inject(StatusLabels);

  transform(code: string): string {
    return this.labels.lookup(code);
  }
}

go deeper

for a junior

Recall that a pipe can use inject() like any other Angular class, and that the service should hold shared data.

for a middle

Explain that pipes resolve from the element where they are used, without that element's component viewProviders, and that each usage creates an instance.

for a senior

Keep pipes stateless, pass changing data as arguments so purity holds, and extract logic into functions rather than injecting pipes.

for a principal

Define where cross-cutting formatting state lives, services, tokens or signals, so pipes stay thin and predictable across teams.

## Injecting into a pipe A pipe class takes part in dependency injection just like a directive. The current style is the **`inject()` function** in a field initializer: ```ts @Pipe({ name: 'statusLabel' }) export class StatusLabelPipe implements PipeTransform { private readonly labels = inject(StatusLabels); transform(code: string): string { return this.labels.lookup(code); } } ``` Constructor parameters work too, but `inject()` is the recommendation for new code. ## Which injector resolves it A pipe is resolved from the **element injector of the node where the pipe is used**, then up the element-injector tree, then the environment injectors, as if the pipe were a directive on that element. Two consequences: - providers declared by a component or directive on an **ancestor element** are visible to the pipe; - a component's **`viewProviders` are not** visible to pipes used on that component's host element; the framework explicitly disables that access when it creates the pipe. Pipes themselves cannot declare `providers`; `@Pipe` has no such option. ## How many instances exist Angular creates **one pipe instance per usage in a view**: - `{{ a | statusLabel }}` and `{{ b | statusLabel }}` in the same template are two instances; - a pipe inside an `@for` row gets a new instance for each row's view; - the instances are destroyed with their view, and a pipe that implements `ngOnDestroy` gets that hook called. So a pipe instance is not a singleton, and state stored on it is per usage. Anything that must be shared, such as a cache of translations, belongs in an injected service. | Question | Answer | |---|---| | Can a pipe call `inject()`? | Yes, in a field initializer or the constructor | | Starting point for resolution | The element where the pipe is used | | Sees the host component's `viewProviders`? | No | | Instances per template | One per usage, per view | | Lifecycle hook available | `ngOnDestroy` | ## What to inject, and what not to - **Good fits**: stateless lookups, formatting configuration such as `LOCALE_ID`, a shared cache. - **Careful**: a service whose data changes over time. A pure pipe will not re-run just because the service's data changed; pass the changing value as an argument, or read it from a signal passed in, so Angular can see the dependency. - **Avoid**: services that perform HTTP calls from `transform`. The pipe is invoked during change detection; a request per evaluation is easy to trigger by accident, and the result arrives asynchronously anyway, which is the async pipe's job. ## The reverse direction: pipes in services The Angular guide advises **against injecting a pipe class into a service or component** to reuse its logic. Pipes are template operators, not injectable services. Extract the transformation into a plain exported function, have the pipe's `transform` delegate to it, and import the function wherever else it is needed. For built-in pipes, the equivalent functions are exported from `@angular/common`: `formatDate`, `formatCurrency`, `formatNumber` and `formatPercent`. ## A worked diagnosis A team reports that a `currencyLabel` pipe keeps returning a stale label after the user switches the display currency in settings. The pipe injects a `Settings` service and reads `settings.currency` inside `transform`. The pipe is pure, and its only input, the amount, did not change, so Angular never called `transform` again. The fix is not `pure: false`; it is to make the dependency visible: `{{ amount | currencyLabel: settings.currency() }}` with the setting held in a signal, so both the view refresh and the pipe re-run happen when it changes. ## Testing a pipe with dependencies 1. For a pipe without dependencies, instantiate it directly and call `transform`. 2. For a pipe that calls `inject()`, create it inside an injection context, for example with `TestBed.runInInjectionContext(() => new StatusLabelPipe())`, after providing test doubles through `TestBed.configureTestingModule`. 3. Test the extracted plain function separately; most of the logic lives there.

  • A pure pipe injects a settings service whose value changes at runtime; why does the output not update?
    A pure pipe re-runs only when its value or arguments change. A change inside an injected service is invisible to that comparison. Pass the changing setting as an argument, ideally read from a signal in the template, so Angular sees the dependency.
  • How do you unit-test a pipe that calls inject()?
    Configure providers with `TestBed.configureTestingModule`, then create the pipe inside an injection context, for example `TestBed.runInInjectionContext(() => new StatusLabelPipe())`, and call `transform` directly.

saying these in an interview costs you the question

  • Believes a pipe is a singleton shared by the whole app
  • Adds a providers array to the @Pipe decorator
  • Expects a pipe to see the host component's viewProviders
  • Makes HTTP calls from a pipe's transform method
  • Injects a pipe class into a service to reuse its logic