The core distinction between pure and impure pipes in Angular and its performance implications

When building a custom pipe in Angular, you have the option to declare it as either pure or impure:

@Pipe({
  name: 'myCustomPipe', 
  pure: false/true        <----- here (default is `true`)
})
export class MyCustomPipe {}

Angular's official documentation covers pipes quite thoroughly, and you can access it here. However, as is often the case with documentation, the underlying reasoning for why pipes are divided into these two categories is not clearly explained. The goal of this article is to bridge that gap by examining the concept from a functional programming standpoint, which is where the notion of pure and impure pipes originates. Beyond just understanding the distinction, you will also gain insight into how this choice impacts performance, which will equip you to build efficient and high-performing pipes in Angular.

Understanding pure functions

The internet is saturated with resources on functional programming, so most developers are likely familiar with the concept of a pure function. My own definition is that a pure function is one that lacks any kind of internal state. This means that none of its operations are influenced by such a state, and when you provide the same arguments, it will consistently return the same deterministic result.

Consider these two separate implementations of a function designed to add numbers. The first one represents a pure function, while the second is an impure one:

const addPure = (v1, v2) => {
  return v1 + v2;
};

const addImpure = (() => {
  let state = 0;
  return (v) => {
    return state += v;
  }
})();

If I invoke both functions with the same argument, for instance the number 1, the first function will consistently yield the output 2 on every single invocation:

addPure(1, 1);  // 2
addPure(1, 1);  // 2
addPure(1, 1);  // 2

in contrast, the second function will return a different value each time:

addImpure(1);  // 1
addImpure(1);  // 2
addImpure(1);  // 3

The fundamental point to grasp here is that even with constant input, an impure function has the potential to generate varying outputs. Consequently, we cannot rely on the input value to predict whether the output will remain the same or shift.

Now, let's examine another significant implication of a function possessing a state. Imagine you have an object named calculator that accepts a number-adding function as an argument and relies on it to perform calculations:

class Calculator {
  constructor(addFn) {
    this.addFn = addFn;
  }

  add(v1, v2) {
    return this.addFn(v1, v2);
  }
}

When the function is pure and devoid of any state, it can be safely passed around and shared among numerous instances of the Calculator class without any issues:

class Calculator {
  constructor(addFn) {
    this.addFn = addFn;
  }

  add(v1, v2) {
    return this.addFn(v1, v2);
  }
}

const c1 = new Calculator(add);
const c2 = new Calculator(add);

c1.add(1, 1); // 2
c2.add(1, 1); // 2

On the other hand, a function that is impure cannot be shared in this way. This restriction arises because the actions taken by one instance of Calculator will modify the function's state, which in turn will alter the outcomes produced for another instance of Calculator:

const add = (() => {
  let state = 0;
  return (v) => {
    return state += v;
  }
})();

class Calculator {
  constructor(addFn) {
    this.addFn = addFn;
  }

  add(v1, v2) {
    return (this.addFn(v1), this.addFn(v2));
  }
}

const c1 = new Calculator(add);
const c2 = new Calculator(add);

c1.add(1, 1); // 2
c2.add(1, 1); // 4  <------ here we have `4` instead of `2`

Observe that the initial invocation of the add method on the second instance results in an output of 4, rather than the expected 2.

Let's summarize what we've established about these two types of functions:

Pure:

  • the output is entirely dependent on the input parameters, so if the parameters stay the same, the output remains unchanged
  • it can be used across many different contexts without any risk of influencing the final result

Impure:

  • the input value cannot be used as a basis for determining whether the output will change
  • it cannot be shared between usages, since its internal state is susceptible to external modifications

Translating this knowledge to Angular pipes

Assume we have created a custom pipe and marked it as pure:

@Pipe({
  name: 'myCustomPipe', 
  pure: true
})
export class MyCustomPipe {}

And then we employ it in a component template in the following manner:

<span>{{v1 | customPipe}}</span>
<span>{{v2 | customPipe}}</span>

Given that the pipe is pure, it is free from any internal state and thus can be shared. How does Angular take advantage of this property? Even though the template contains two separate usages, Angular has the ability to instantiate just a single pipe object which can be shared across both call sites. For readers who are familiar with component factory concepts from my earlier writings, the relevant compiled code below shows that only one pipe definition is generated:

function View_AppComponent_0(_l) {
  return viewDef_1(0, [
    pipeDef_2(0, ExponentialStrengthPipe_3, []), // node index 0
    ...

which is then reused within the updateRenderer function:

function(_ck,_v) {
    unwrapValue_7(_v,4,0,_ck(_v,5,0,nodeValue_8(_v, 0),...);
                                                   ^^^
    unwrapValue_7(_v,8,0,_ck(_v,9,0,nodeValue_8(_v, 0),...);
                                                   ^^^

Notice that the unwrapValue function is responsible for obtaining the current pipe value by invoking the transform method on it. The pipe instance is located using the node index passed to the nodeValue function call, which is 0 in this particular scenario.

However, when we designate the pipe as impure, assuming it maintains some internal state:

@Pipe({
  name: 'myCustomPipe', 
  pure: false
})
export class MyCustomPipe {}

We need to ensure that the pipe used in the second location is not impacted by the invocation in the first location. Therefore, Angular generates two separate instances of the pipe, each possessing its own independent state:

function View_AppComponent_0(_l) {
   return viewDef(0, [
       ...
       pipeDef_2(0, ExponentialStrengthPipe, []) // node index 4
       ...
       pipeDef_2(0, ExponentialStrengthPipe, []) // node index 8

and this instance is not reused within the updateRenderer function:

function(_ck,_v) {
    unwrapValue_7(_v,4,0,_ck(_v,5,0,nodeValue_8(_v, 4),...);
                                                   ^^^
    unwrapValue_7(_v,8,0,_ck(_v,9,0,nodeValue_8(_v, 8),...);
                                                   ^^^

As you can observe, instead of the node index 0, Angular now utilizes distinct node indices for each usage, namely 4 and 8 respectively.

The second conclusion drawn from the first section was that with pure functions, the input parameters can be used to determine if the output will change, whereas with impure functions, such a guarantee does not exist.

In Angular, we pass inputs to a pipe in this way:

<span>{{v1 | customPipe:param1:param2}}</span>

Thus, for a pure pipe, we can be certain that its output (via the transform method) is exclusively a product of its input parameters. If those parameters remain unchanged, the output will also remain constant. This logic allows Angular to apply an optimization: the transform method is invoked only when the input parameters are modified.

However, with an impure pipe that possesses an internal state, identical parameters do not ensure an identical output, as we demonstrated with the impure addFn function earlier. This forces Angular to execute the transform function on the pipe instance during every digest cycle.

An effective illustration of an impure pipe is the AsyncPipe found in the @angular/common package. This pipe maintains an internal state that stores the subscription created when it subscribes to the observable passed in as its argument. As a result, Angular must instantiate a new pipe for every usage to prevent one observable from interfering with another. Additionally, the transform method must be called on each digest cycle because, despite the observable argument staying the same, new data may arrive through the observable that requires processing by the change detection system.

Two other pipes that fall into the impure category are the JsonPipe and the SlicePipe. Angular imposes an additional constraint for a pipe to be classified as pure: the pipe's input must not be a mutable object. If the input is subject to mutation, the pipe needs to be re-evaluated during every digest cycle, because an input object can be altered without changing its reference (so the pipe parameter remains the same). This rationale explains why both the JsonPipe and the SlicePipe are not considered pure, even though they lack an internal state.

All of the other built-in Angular pipes are classified as pure.

Final thoughts

As we have demonstrated, impure pipes can introduce a significant performance burden if they are not used with caution and deliberation. This performance cost originates from the fact that Angular must create multiple instances of an impure pipe and also execute its transform method during every digest cycle.

After going through this article, you should now have a clear understanding of the differences between these two pipe types, how Angular processes each one, and the appropriate mental framework to adopt when you are designing and implementing your own custom pipes.