Pošlite nám správu

Spojte sa s nami

Telefonné číslo

+421 948 490 415

Edit Template

Angular pipes: Time to rethink

Tento článok je dostupný len v anglickom jazyku.

Angular’s toolkit provides developers with just a few core primitives to solve a wide range of tasks. In my previous articles, we’ve already explored a fundamental concept — Directives, examining the technical nuances and trying to articulate the core idea behind it.

This time, we’ll take a closer look at Pipes, whose role in Angular is becoming a subject of reconsideration with the introduction of the new reactivity system.

Along the way, we’ll refresh a few important technical aspects and try to figure out how significant pipes are in shaping modern Angular-based architectures.

Pipes and the compiler

Before we discuss the idea behind pipes, let’s take a look at how Ivy handles them in the source code. As an example, we’ll use the following template:

				{{ value | date }}
			

🔍 Now let’s use Matthieu Riegler’s online template compiler to inspect the template function after compilation:

				function TestCmp_Template(rf, ctx) {
  if (rf & 1) { // create
    ɵɵtext(0);
    ɵɵpipe(1, 'date');
  }
  if (rf & 2) { // update
    ɵɵtextInterpolate(ɵɵpipeBind1(1, 1, ctx.value));
  }
}
			

If you’ve ever come across Ivy, you’re likely familiar with the two phases that include the instructions generated by the compiler, the creation phase and the update phase, which the runtime distinguishes using simple bit-flag checks.

From the template function above, it’s clear that there is a separate instruction ɵɵpipe responsible for initializing a pipe instance during the component’s creation phase:

				export function ɵɵpipe(index: number, pipeName: string): any {
  const tView = getTView();
  // ... 
}
			

Ivy uses several internal data structures. In this article, we’ll focus on two of them: tView (template view) and lView (logical view). The tView represents the view’s blueprint, describing its overall structure and static layout, while the lView holds the data for a specific instantiated view:

By leveraging tView, we can locate the next important blueprint for creating a pipe instance — the PipeDef, which is stored in the pipeRegistry array of the template view:

				export function ɵɵpipe(index: number, pipeName: string): any {
  const tView = getTView();
  let pipeDef: PipeDef<any>;

  if (tView.firstCreatePass) {
++    pipeDef = getPipeDef(pipeName, tView.pipeRegistry)!;
  }
}
			

💡 For additional optimization, this blueprint is stored in a separate data slot within the tView, so for other instances of the same component, it can be reused as-is:

				tView.data[adjustedIndex] = pipeDef;
			

This way, Ivy retrieved the pipe definition by its name, which includes the factory for creating it. However, before calling the factory, it’s definitely worth considering the context that’s set up for DI, since, as we know, pipes can inject dependencies.

🧬 Pipes and DI

While working with DI, you may want to understand some aspects of pipes behavior. First of all, pipes are not nodes in the element-level dependency hierarchy and do not register themselves as dependencies.

Dependencies for a pipe are resolved in the same way as if we declared a directive on the host element, excluding the component host’s viewProviders:

				const pipeFactory = pipeDef.factory;
setInjectImplementation(ɵɵdirectiveInject); // DI context setup
setIncludeViewProviders(false); // DI context setup
const pipeInstance = pipeFactory(); // pipe factory calling
			

After the factory is called, the created pipe instance is stored in one of the data slots of the lView:

				store(tView, getLView(), adjustedIndex, pipeInstance);
			

Therefore, it can be used in subsequent component updates — which we’ll discuss next.

🧩 Pipes and bindings

Having briefly looked at the ɵɵpipe instruction during the creation phase, we can now move on to how the pipe works during the component’s update phase.

In the compiler output mentioned earlier:

				if (rf & 2) { // update
  ɵɵtextInterpolate(ɵɵpipeBind1(1, 1, ctx.value));
}
			

you may have already noticed the ɵɵpipeBind1 instruction, into which the compiler passes:

  • 🔢 index used to retrieve the pipe instance from the lView slots
  • 📐 offset for calculating the index when accessing binding data from lView slots (we’ll discuss why this is needed)
  • 🎯 value for the incoming argument (in our case, the single argument)

The behavior of this instruction differs depending on whether the pipe is pure or not (remember, the PipeDef contains all the necessary information about it):

				export function ɵɵpipeBind1(index: number, offset: number, v1: any): any {
  // ...
  return isPure(lView, adjustedIndex)
    ? pureFunction1Internal(/* ... */)
    : pipeInstance.transform(v1);
}
			

📝 A bit about terminology: if you’re just getting familiar with Angular and know the term pure function, it’s important not to confuse it with the concept of pure pipes. Despite the word “pure”, a pipe’s transform function is not strictly a pure function in the FP sense.

As can be seen from the instruction body, for pure pipes a separate function is called, whereas for impure pipes the compiler directly calls the .transform method.

🧪 Pipes and purity

Pure pipes memoize the .transform method to avoid unnecessary computations. Memoization is implemented by simply comparing the previous values of the pipe’s arguments (which are stored in the lView slots) with the values passed at the time of ɵɵpipeBind* invocation:

				function bindingUpdated(lView: LView, bindingIndex: number, value: any) {
  // ...
  const oldValue = lView[bindingIndex];
  
  if (Object.is(oldValue, value)) {
    return false;
  } else {
    lView[bindingIndex] = value;
    return true;
  }
}
			

💡 This way, the compiler doesn’t incur any additional overhead for hashing the input arguments, as they are stored directly in the binding slots alongside the regular bindings:

The result of a pipe’s computation is also stored in one of the lView slots. This is the cached result from the previous update, which Ivy will return from the slot if the pipe’s bindings haven’t changed.

📥 Pipes and input arguments

You might also find it interesting why Ivy has separate instructions for pipes with either a fixed or a variable number of arguments:

				ɵɵpipeBind1() // for 1 arg
ɵɵpipeBind2() // for 2 args
ɵɵpipeBind3() // for 3 args
ɵɵpipeBind4() // for 4 args
ɵɵpipeBindV() // variable number of arguments
			

The answer lies in an additional compiler optimization. Let’s try to define a template where a pipe is used with more than four arguments:

				//  [1]----------[2]----[3]-----[4]-----[5]
{{ value | pipe: arg : 'arg' : 'arg' : 'arg' }}

// [1], [2] - dynamic
// [3], [4], [5] - static
			

and take a look at the compiler output:

				ɵɵdefineComponent({
  template: function TestCmp_Template(rf, ctx) {
    // ...
    if (rf & 2) {
      ɵɵtextInterpolate(
        ɵɵpipeBindV(1, 1, ɵɵpureFunction2(7, _c0, ctx.value, ctx.arg))
      );
    }
  }
  // ...
});

const _c0 = (a0, a1) => [a0, a1, 'arg', 'arg', 'arg']; // 👀
			

To handle this, the compiler generates a separate factory that receives the dynamic values from the context and returns an array of arguments combined with the static ones:

				const _c0 = (/* dynamic args */) => [/* ...dynamic */, /* ...static */];
			

But how does Ivy handle the fact that calling the function would create a new array on every update cycle? Then, it memoizes this function (🙂):

				ɵɵpipeBindV(
   1, 
   1, 
++ ɵɵpureFunction2(8, _c0, ctx.value, ctx.arg)
);
			

Consequently, extra slots in lView are required to hold the function’s arguments as well as its result.

💡 Since all slots are pre-allocated to avoid holes, Ivy has separate instructions for a certain fixed number of arguments (1–4), which allows it to avoid reserving additional slots for memoizing the argument factory.

⚙️ Why the compiler matters

A general understanding of how the compiler works allows us to grasp what happens with pipes when they are used — when and based on what a pipe is created, when and how its value is computed, and what overhead is involved. This knowledge is especially important when creating custom pipes, whose usage can be quite diverse.

What is the future of pipes?

With the plans to introduce selectorless components, it is expected that the Angular team will also need to revisit how pipes are used in templates.

As you may have noticed in our compiler walkthrough, a component requires a static blueprint — the tView (which contains the registry of pipes). Each pipe also needs its own blueprint — PipeDef, describing its metadata (especially the pipe’s name, which we use in the template) and including the factory for its creation.

💡 The compiler gathers all this information through a static analysis of the entire application during compilation, taking into account Ivy’s current architecture (read more).

Rethinking the compiler’s architecture may also lead one to reconsider the role of pipes in general, as there is already a dedicated issue on GitHub. With the introduction of the new reactivity system, we are faced with a philosophical question — what place do pipes have in this concept?

📍 Pipes ≠ computed signals

To understand the place of pipes in modern Angular — we need to discuss how pipes intersect with the existing concepts of the new reactivity system.

Reflecting on the memoization of pure pipes, it’s natural to wonder whether we could alternatively use computed signals. However, there are some important nuances to consider.

Angular pipes are designed to move computations outside the model. Thanks to the compiler’s capabilities, this allows the model to stay free of any display-related logic:

				@for (product of products(); track product.id) {
  🏷️ Price: {{ product.price | currency }}
  🗓️ Added: {{ product.createdAt | date }}
}
			

Analyzing the example above, we can see the all-encompassing capabilities of pipes. First of all, transforming data in the template does not affect the underlying model, so the data can still be used in further computations (the price remains a number, the date remains a Date object or a timestamp).

Moreover, we can apply these transformations on every iteration of a loop directly in the template, unlike computed signals, which can only be created at the component’s .ts level.

The pipe’s logic is encapsulated within its class, with the ability to inject dependencies for configuration both across the entire application and, when needed, at specific component boundaries.

💡 For example, we can configure the built-in DatePipe globally just once so we don’t have to pass the format manually each time:

				providers: [
  {  
    provide: DATE_PIPE_DEFAULT_OPTIONS, 
    useValue: { dateFormat: 'dd/MM/yyyy' }
  }
]
			

💡 Pipes can be configured not only statically through DI but also reactively. To achieve this, it’s enough to use impure pipes in combination with signals, which are memoized out of the box (read more):

				@Pipe({ 
  name: 'translate',
  pure: false // 📌
})
export class TranslatePipe implements PipeTransform {
  dictionary = inject(DICTIONARY); // Signal<Record<string, string>
  transform = (key: string) => this.dictionary()[key];
}
			

In this case, changing the signal’s value will schedule change detection, and the pipe’s result will be recalculated by calling .transform.

From our compiler analysis, you may also remember that the .transform method of impure pipes is always called during the update phase. For that reason, in the impure-pipe scenario, we use it only to “extract” a value, without performing any significant computations. Instead, those computations should be performed inside the reactive signal graph.

📍 Pipes ≠ just formatters

When talking about pure pipes, it’s easy to fall into the mindset that pipes simply format data — turning A into B, without any side effects.

But that isn’t always true. Pipes can inject dependencies, hold internal state, and (because they can hook into the component’s destruction lifecycle) handle side effects.

As an example, let’s consider the impure built-in AsyncPipe, which:

  • manages subscriptions to observables
  • stores the most recently emitted value
  • schedules change detection when a new value is emitted
  • handles unsubscription when the view is destroyed

❓ By the way, try writing in the comments — why isn’t AsyncPipe a pure pipe? It’s a great test of how well you understand how pipes work.

Even though the usage of the AsyncPipe itself is steadily fading away, the underlying mechanism remains. And this mechanism enables pipes to be much more than simple formatters.

Viewed from this angle, pipes embody the idea that ‘with great power comes great responsibility.’ In practice, choosing to use custom pipes with state and side effects should be a well-considered decision, as improper use can easily lead to performance issues and make debugging significantly harder.

Interesting use cases

In the hope that future pipes will retain the full power of what they can do, let’s break things up with a few interesting examples.

🔗 Unifying formatters under a single pipe

If we don’t want to create a separate pipe for every formatter, we can instead define a single pipe that accepts it as an argument:

				@Pipe({ name: 'map' })
export class MapPipe implements PipeTransform {
  transform = <V, R, Args extends unknown[]>(
    value: V,
    fn: (...args: [V, ...Args]) => R,
    ...args: Args
  ): R => fn(value, ...args);
}
			

And since the formatter’s reference doesn’t change, this argument won’t cause the result to be recomputed during updates:

				// component’s .ts (date-fns)
isToday = (date: Date) => isSameDay(date, Date.now());

// ▶️ Usage:
@for (day of days(); track day.getTime()) {
  @let today = day | map: isToday;
}
			

🌀 Working around the compiler’s limitations

If you’re coming to Angular from Vue and miss v-for="n in X”, you can play around with this using a pipe:

				@Pipe({ name: 'range' })
export class RangePipe implements PipeTransform {
  transform = (length: number) => Array.from({ length }, (_, i) => i);
}

// ▶️ Usage:
@for (n of 10 | range; track n) {
  <span>{{ n }}</span>
}
			

Actually, if the introduction of a built-in range mechanism is relevant to you, you can draw more attention to this open issue.

⏱️ Playing around with asynchronous computations

Another example for a general understanding of impure pipes (here, knowledge of RxJS will also come in handy):

				@Pipe({ name: 'countdown', pure: false /* 📌 */ })
export class CountdownPipe implements PipeTransform {
  readonly #cdr = inject(ChangeDetectorRef);
  readonly #duration = new Subject<number>();
  readonly #changes = this.#duration.pipe(distinctUntilChanged());
  #result?: string;

  constructor() {
    this.#changes
      .pipe(
        switchMap(duration =>
          timer(0, 1000).pipe(
            map(tick => duration * 60 - tick),
            takeWhile(seconds => seconds >= 0)
          )
        ),
        takeUntilDestroyed()
      )
      .subscribe(seconds => {
        this.#result = this.format(seconds);
        this.#cdr.markForCheck();
      });
  }

  transform(duration: number): string {
    this.#duration.next(duration);
    return this.#result || '';
  }

  format(totalSeconds: number): string {
    const minutes = Math.floor(totalSeconds / 60);
    const seconds = totalSeconds % 60;
    return `${minutes}:${seconds.toString().padStart(2, '0')}`;
  }
}

// ▶️ Usage:
Registration closes in {{ 5 | countdown }} minutes!
			

As seen in the example, we can delegate cache management and change detection scheduling to the source code of our pipe.

Conclusion

If we compare Angular to a toolbox, pipes aren’t exactly the kind of tool you absolutely can’t live without. But without them in the box, you’d very likely run into a series of inconveniences.

This article isn’t a rethinking of pipes that comes with any proposal for what should happen to them next. It’s more of a reflection — a reminder of how valuable pipes are in the ecosystem and why it’s important to preserve their key qualities in one form or another.

Tento článok si môžete prečítať na našom Medium

O nás

Vitajte na našom blogu! Prinášame inovatívne a efektívne riešenia a delíme sa o naše skúsenosti, aby ste mohli rásť spolu s nami. 

Odporúčané články

Najnovšie články

  • All Post
  • Branding
  • Desktopové aplikácie
  • Development
  • Leadership
  • Management
  • Mobilné aplikácie
  • Nezaradené
  • Projekty
  • Webové aplikácie

Zistite viac o našej spoločnosti

Navrhujeme softvérové riešenia, ktoré posunú váš biznis vpred!

CORETEQ Technology s.r.o.