What sets map and pluck apart in RxJS

Would you expect the two code snippets below to produce identical outcomes?

from(objectList).pipe(
  map(object => object.employee),
  map(employee => employee.address),
  map(address => address.houseNumber)
)
// vs
from(objectList).pipe(
  pluck('employee', 'address', 'houseNumber')
)

Actually, they don’t behave the same way. Let’s dig into the details to uncover the subtle distinction.

The mechanics of the map operator

Per the RxJS documentation, the map operator does this:

Takes a project function and applies it to every value coming from the source Observable, then emits those transformed results as a new Observable.

However, there’s more to it than that. What happens if the project function throws an exception? Looking closely at the internals of map reveals the following.

try {
  result = this.project.call(this.thisArg, value, this.count++);
} catch (err) {
  // if error occurs, map will emit an error notification and return
  this.destination.error(err); // <-- this line
  return;
}

Under the hood of map

From this implementation, it’s evident that when the project function encounters an error, map emits an error notification, and your observable effectively stops producing further values.

The mechanics of the pluck operator

According to the official documentation, pluck does the following:

Takes each source value (which is an object) and maps it to a specific nested property.

That description raises a natural question: what occurs when the requested nested property isn’t present in the object?

Digging into the source code of pluck provides the answer.

export function pluck<T, R>(...properties: string[]): OperatorFunction<T, R> {
  // if you pass pluck('employee', 'address', 'houseNumber')
  // the length will equal to 3
  const length = properties.length;
  ...
  // under the hood, pluck operator calls map operator,
  // and passes the plucker as projection function
  return (source: Observable<T>) => map(plucker(properties, length))(source as any);
}

What pluck does internally

It turns out that pluck uses map behind the scenes, passing a function called plucker as the project function. Here’s what the plucker does.

// if you call pluck('employee', 'address', 'houseNumber')
// props will be ['employee', 'address', 'houseNumber']
// and length will be 3
function plucker(props: string[], length: number): (x: string) => any {
  const mapper = (x: string) => {
    let currentProp = x;
    // loop through every passed properties in the list and get the nested value from object
    for (let i = 0; i < length; i++) {
      // if the object doesn't have the specified property, no error will be thrown...
      const p = currentProp != null ? currentProp[props[i]] : undefined; // <--this line
      if (p !== void 0) {
        currentProp = p;
      } else {
        // ...instead, it returns undefined
        return undefined; // <-- this line
      }
    }
    return currentProp;
  };

  return mapper;
}

Based on this code, pluck retrieves a nested value from an object using the list of property names you supply. For instance, if you invoke pluck('employee', 'address', 'houseNumber'), it attempts to access object.employee.address.houseNumber, but with one key difference: it’s null-safe.

If a nested value is missing, pluck returns undefined and keeps the stream flowing to the next emission, rather than triggering an error that halts the stream as map would. That’s the essential difference between these two operators.

A quick recap with an example

To tie everything together, consider this concrete scenario. Say you have the following input data:

const arr = [
  {
    employee: {
      address: {
        houseNumber: 1
      }
    }
  },
  {
    employee: {
      // notice this employee doesn't have address
    }
  },
  {
    employee: {
      address: {
        houseNumber: 3
      }
    }
  },
];

const arr$ = interval(1000).pipe(
  map(index => arr[index]),
  take(3)
);

Now, you have two separate streams:

const streamWithMap = arr$.pipe(
  map(object => object.employee),
  map(employee => employee.address),
  map(address => address.houseNumber)
);

const streamWithPluck = arr$.pipe(
  pluck('employee', 'address', 'houseNumber')
);

Here’s how streamWithMap and streamWithPluck behave visually, in order:

Subtle difference between map and pluck RxJS operators that you should know — figure 1

streamWithMap

Subtle difference between map and pluck RxJS operators that you should know — figure 2

streamWithPluck

If you adjust streamWithMap in the following way, you’ll get the same result as using pluck:

const streamWithMap = arr$.pipe(
  map(object => object?.employee?.address?.houseNumber),
);

Final takeaway

This article has walked through the key differences between RxJS’s map and pluck operators, looking closely at their implementations. I’ve also provided a practical example along with marble diagrams to highlight the contrast.

I trust you picked up something valuable. Thanks for reading.