Toolkit - map / filter / reduce in arrays

marwaneb

Marwan El Boussarghini

Posted on October 22, 2019

Toolkit - map / filter / reduce in arrays

In the last year, I have been extremely interested in functional and declarative programming.
In fact after practising and learning how to write functional JavaScript, I find this code extremely smooth to read, very compact and easy to change (probably a nice subject for a future article).

On my journey, I have found myself chaining a lot of functions as in the below example and I thought it would be interesting to summarize the three main methods that I use with arrays.

randomElement
  .method1(callBack1) // do this
  .method2(callBack2) // then do that
  .method3(callBack3) // ...

"Why arrays?" you might ask. Well I think that's probably the data structure that I find myself manipulating the most with objects (and when I need to iterate on objects, I tend to use Object.keys() or Object.entries() that will basically convert my object to an array).

An other thing that I find extremely really important with those three methods is that they are pure (as long as the callback you give them is pure) which means that they:

  • have no side-effects on your code;
  • won't modify the initial array and create a brand new array (or value).

Map

The map() method creates a new array with the results of calling a provided function on every element in the calling array.

map as a GIF

In a nutshell, map accepts as an argument a function that will transform each element of the array. It takes 2 arguments as below:

arr.map(
  (element, index, array) => // element of the array,
  thisArgument, // In case you use "this" inside the function above. I totally DISSUADE you from using it.
);

map has a lot of use when it comes to formatting elements of an array and it's probably one of function that I use the most on a daily basis. Here are below few examples of map usage.

// Format an object.

const users = [
  { id: 1, firstName: "Charles", lastName: "Winston" },
  { id: 2, firstName: "Brad", lastName: "Dai" },
  { id: 4, firstName: "John", lastName: "Doe" },
];

const getId = ({ id }) => id;
users.map(getId); // [1, 2, 4]

const formatNames = ({ firstName, lastName, ...rest }) => ({
  ...rest,
  fullName: `${firstName} ${lastName}`,
});
users.map(formatNames);
/*
[
  { id: 1, fullName: "Charles Winston" },
  { id: 2, fullName: "Brad Dai" },
  { id: 4, fullName: "John Doe" },
]
*/
// Iterate to generate different element like with JSX and React.

const users = [
  { id: 1, firstName: "Charles", lastName: "Winston", companyId: 1 },
  { id: 2, firstName: "Brad", lastName: "Dai", companyId: 12 },
  { id: 4, firstName: "John", lastName: "Doe", companyId: 19 },
];

const renderUser = (
  { id, firstName, lastName },
  index
) => (
  <li key={id}>{index} - {firstName} {lastName}</li>
);
const UserList = <ul>{users.map(renderUser)}</ul>;
/*
<ul>
  <li key={1}>1 - Charles Winston</li>
  <li key={2}>2 - Brad Dai</li>
  <li key={4}>3 - John Doe</li>
</ul>
*/
// To add information to an object.

const companies = [{ id: 1, name: "Apple" }, { id: 19, name: "Google" }]
const addCompanyToUser = ({ companyId, ...rest }) => ({
  ...rest,
  company: companies.find(({ id }) => companyId === id) || null,
});

users.map(addCompanyToUser)
/*
[{
  id: 1, firstName: 'Charles', lastName: 'Winston', company: { id: 1, name: 'Apple' },
},{
  id: 2, firstName: 'Brad', lastName: 'Dai', company: null,
}, {
  id: 4, firstName: 'John', lastName: 'Doe', company: { id: 19, name: 'Google' },
}]
 */

Filter

The filter() method creates a new array with all elements that pass the test implemented by the provided function.

filter() is a simpler function: it allows to create a new sub-array based on a predicate (function that returns a boolean) and filter (wow) your array.

const messages = [
  { id: 1, message: 'Hello Johnny', recipientId: 3 },
  { id: 2, message: 'Hey Nick!', recipientId: 80 },
  { id: 3, message: 'How are you doing?', recipientId: 3 },
  { id: 4, message: 'See you around sis!', recipientId: 99 },
];

const isMessageForUser = id => ({ recipientId }) => id === recipientId;

messages.filter(isMessageForUser(3));
/*
[
  { id: 1, message: 'Hello Johnny', recipientId: 3 },
  { id: 3, message: 'How are you doing?', recipientId: 3 },
]
*/
const messages = [
  { id: 1, message: 'Hello Johnny', recipientId: 3 },
  { id: 2, message: 'Hey Nick!', recipientId: 80 },
  { id: 3, message: 'How are you doing?', recipientId: 3 },
  { id: 4, message: 'See you around sis!', recipientId: 99 },
  { id: 5, message: 'See you around bro!', recipientId: 80 },
];

const isMessageInPage = (pageSize, offset) => (_, index) =>
  (offset * pageSize <= index) && (index < (offset + 1) * pageSize);

messages.filter(isMessageInPage(2, 1));
/*
[
  { id: 3, message: 'How are you doing?', recipientId: 3 },
  { id: 4, message: 'See you around sis!', recipientId: 99 },
]
*/

Real world example with Map and Filter

What I find really interesting in this functions is the way you can chain them. It makes the code really easy to debug, to read and to make evolve.

Here is for example a sample of code to parse logs coming for example from a CloudWatch instance:

const logExample = '{"messageType":"DATA_MESSAGE","owner":"123456789123","logGroup":"testLogGroup","logStream":"testLogStream","subscriptionFilters":["testFilter"],"logEvents":[{"id":"id1","timestamp":1440442987000,"message":"[INFO] First test message", origin: "application", "level": "INFO" },{"id":"id2","timestamp":1440442987001,"message":"[ERROR] Second test message", "origin": "nginx", "level": "ERROR" },{"id":"id3","timestamp":1440442987000,"message":"[INFO] Third test message", "origin": "application", "level": "INFO" }]}';

const getLogEvents = logStream => {
  try { return JSON.parse(logStream).logEvents; }
  catch(e) { return []; }
};
/*
Expected output:
[
  { id:"id1", timestamp:1440442987000, message:"[INFO] First test message", origin: "nginx", level: "INFO" },
  { id:"id2", timestamp:1440442987001, message:"[ERROR] Second test message", origin: "application", level: "ERROR" },
  { id:"id3", timestamp:1440442987000, message:"[WARN] Third test message", origin: "application", level: "WARNING" },
]
*/

const isOriginExcluded = excludedOrigins => ({ origin }) => !excludedOrigins.includes(origin);
const isLevelInList = levelList => ({ level }) => levelList.includes(level);
const formatLog = ({ message }) => message;

const errorMessages = getLogEvents(logExample)
  .filter(isOriginExcluded(['nginx', 'syslog'])) // Exclude system logs.
  .filter(isLevelInList(['ERROR'])) // Only keep error logs
  .map(formatLog); // Extract the messages.
// Output: ["[ERROR] Second test message"]

With this kind of code, I find extremely clear how the logs are processed and the shape of the different outputs.
This is particularly helpful when someone else comes and want to make some modifications.

Reduce: sometimes simple is not enough

The reduce() method executes a reducer function (that you provide) on each element of the array, resulting in a single output value.

reduce as a GIF

Here comes the final boss of the array methods. Before digging in why this function is so powerful, let's have a look at its parameters.

arr.reduce(
  (accumulator, currentValue, index, initialValue) => { /* should return the new value of the accumulator */ }, // reducer that will be applied to each element.
  initialValue, // First value of the accumulator (by default the first value of the array).
);

The best way I found to understand what it does is actually to write it down in a imperative way:

const arr;
const reducer;
const initialValue;

const result = arr.reduce(reducer, initialValue);

// Is basically equivalent to the code below.

if (initialValue === undefined && !arr.length) { throw new TypeError(); }
let result = initialValue || arr[0];

arr.forEach((element, index) => {
  result = reducer(result, element, index, arr);
});

This method enables among other things to combine elements of a list...

// Sum all the element of a list.
[1,2,3,4].reduce((acc, el) => acc + el) // 10

// Or something more complex like gathering purchases in a list of objects.
const purchases = [
  { id: 1, userId: 53, apples: 1 },
  { id: 2, userId: 90, apples: 3 },
  { id: 3, userId: 53, apples: 5 },
  { id: 4, userId: 90, apples: 2 },
];

const replaceElementAtIndex = arr => (el, index) => [
  ...arr.slice(0, index), el, ...arr.slice(index + 1),
];
purchases.reduce(
  (acc, ({ userId, apples })) => {
    const userIndex = acc.findIndex(el => el.userId === userId);
    return userIndex === -1
      ? [...acc, { userId, apples }]
      : replaceElementInArray(acc)({
        ...acc[userIndex],
        apples: acc[userIndex].apples + apples,
      }, userIndex);
  }, []); // [ { userId: 53, apples: 6 }, { userId: 90, apples: 5 } ]

... but can also be used in a lot of different context. Since the output is not defined, you have limitless possibilities (you can even reimplement all the pure methods of Array prototype with it).

I would however not use this method in all situations: here is a pros / cons of the reduce() method and when to use it over map() / filter().

Pros

  • Can return something else an array.
  • Implements any behaviour (even some more complex manipulating objects, promises ...)

Cons

  • Less descriptive than map() and filter().
  • Harder to read on a first look (probably because it's exposing the logic around the accumulator).
  • Usually needs few back and forth to find the optimal way to write the reducer.

That being said, I totally advise you to start playing it - you will see, power is enjoyable 😉.

How I feel after using reduce

Cheat sheet

Method Expected output Example
map() Array with the same number of elements. [1,2,3].map(x => x * 2) // [2, 4, 6]
filter() Array with less elements. [1,2,3].filter(x => x > 1) // [2, 3]
reduce() Anything else. [1,2,3].reduce((acc, x) => acc + x) // 6

Resources

  1. Array.prototype.map()
  2. Array.prototype.filter()
  3. Array.prototype.reduce()

This post has been originally posted my blog: click here if you want to read more articles 🙂

💖 💪 🙅 🚩
marwaneb
Marwan El Boussarghini

Posted on October 22, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related