Extending pure utility functions of Ramda.js

Neenu Chacko

By Neenu Chacko

on May 30, 2023

Introduction

At BigBinary, we are always looking to improve our code. Ramda's focus on functional-style programming with immutable and side-effect-free functions aligns with this goal, making it our preferred choice.

While working on neeto, we found the need for specific functions that could be applied across a range of products but were not already included in Ramda. We extended Ramda's functions to meet this need and created our own pure utility functions.

In this blog, we'll explore our motivation for creating these functions and the benefits they provide, showcasing how they can be generally applicable to a wide range of products.

The matches function : the core of our pure utility functions

During the development of neeto, we encountered instances where long conditional chains were used to search for objects with deeply nested properties.

For example, consider this userOrder object:

1const userOrder = {
2  id: 12356,
3  user: {
4    id: 2345,
5    name: "John Smith",
6    role: "customer",
7    type: "standard",
8    email: "john@example.com",
9  },
10  amount: 25000,
11  type: "prepaid",
12  status: "dispatched",
13  shipTo: {
14    name: "Bob Brown",
15    address: "456 Oak Lane",
16    city: "Pretendville",
17    state: "Oregon",
18    zip: "98999",
19  },
20};

We can check if this order is deliverable like this.

1const isDeliverable =
2  userOrder.type === "prepaid" &&
3  useOrder.user.role === "customer" &&
4  userOrder.status === "dispatched";

This approach works but it can be simplified.

Our goal was to simplify the process by focusing on comparing all the keys of the pattern to the corresponding keys in the data. If the pattern matches with the object, the function should return true. With that in mind we developed a function named matches to determine if a given object matches a specified pattern.

With matches we should be able to rewrite isDeliverable as:

1const isDeliverable = matches(DELIVERABLE_ORDER_PATTERN, userOrder);

Here the DELIVERABLE_ORDER_PATTERN is defined as:

1const DELIVERABLE_ORDER_PATTERN = {
2  type: "prepaid",
3  status: "dispatched",
4  user: { role: "customer" },
5};

This is how we implemented the matches function:

1const matches = (pattern, object) => {
2  if (object === pattern) return true;
3
4  if (isNil(pattern) || isNil(object)) return false;
5
6  if (typeof pattern !== "object") return false;
7
8  return Object.entries(pattern).every(([key, value]) =>
9    matches(value, object[key])
10  );
11};

Here, we noticed a limitation in this implementation of the matches function. It compared the keys and values in the data and the pattern only for strict equality. We were not able to use the matches function for a situation like the one mentioned below.

To check if the userOrder is being shipped to the city of Michigan or Oregon, we were not able to call matches function on the key state. Instead, we had to use the following approach along with other conditions.

1const isToBeShippedToMichiganOrOregon =
2  ["Michigan", "Oregon"].includes(userOrder.shipTo.state) &&
3  // other long chain of conditions

To cover that, we decided to allow functions as key values in the pattern object. With this change, we should be able to write the same as the following.

1matches(
2  {
3    shipTo: { state: state => ["Michigan", "Oregon"].includes(state) },
4    // ...other properties
5  },
6  userOrder
7);

Here is the modification we have made to the matches function to accomplish this feature:

1const matches = (pattern, object) => {
2  if (object === pattern) return true;
3
4  if (typeof pattern === "function" && pattern(object)) return true;
5
6  if (isNil(pattern) || isNil(object)) return false;
7
8  if (typeof pattern !== "object") return false;
9
10  return Object.entries(pattern).every(([key, value]) =>
11    matches(value, object[key])
12  );
13};

As a result of these improvements, the matches function can now handle a wider range of patterns.

1const user = {
2  firstName: "Oliver",
3  address: { city: "Miami", phoneNumber: "389791382" },
4  cars: [{ brand: "Ford" }, { brand: "Honda" }],
5};
6
7matches({ cars: includes({ brand: "Ford" }) }, user); //true
8matches({ firstName: startsWith("O") }, user); // true

Here, both includes and startsWith are methods from Ramda and they are both curried functions. We will be talking about currying of functions in the upcoming section.

neeto's pure utility functions for array operations

With the help of the matches function, it became easier for us to work on our next task at hand: building utility functions that simplify array operations.

*By functions

Let's say we have an array of users:

1const users = [
2  {
3    id: 1,
4    name: "Sam",
5    age: 20,
6    address: {
7      street: "First street",
8      pin: 123456,
9      contact: {
10        phone: "123-456-7890",
11        email: "sam@example.com",
12      },
13    },
14  },
15  {
16    id: 2,
17    name: "Oliver",
18    age: 40,
19    address: {
20      street: "Second street",
21      pin: 654321,
22      contact: {
23        phone: "987-654-3210",
24        email: "oliver@example.com",
25      },
26    },
27  },
28];

If we need to retrieve the details of user with the name Sam, we will do it like this in plain vanilla JS:

1const sam = users.find(user => user.name === "Sam");

Since we already have the matches function, we could easily create a utility function that would return the same result as above while removing extra code.

That's how we came up with the findBy function that can be used to find the first item that matches the given pattern from an array.

We defined findBy like this:

1const findBy = (pattern, array) => array.find(item => matches(pattern, item));

Now we were able to rewrite our previous array operation as:

1const sam = findBy({ name: "Sam" }, users);

It was also now possible for us to write nested conditions like these:

1findBy({ age: 40, address: { pin: 654321 } }, users);
2// returns details of the first user with age 40 whose pin is 654321
3findBy({ address: { contact: { email: "sam@example.com" } } }, users);
4// returns details of the first user whose contact email is "sam@example.com"

We adopted the concept of currying from Ramda to shorten our function definitions and their usage.

Currying is a technique in functional programming where a function that takes multiple arguments is transformed into a sequence of functions, each taking a single argument. Simply said, currying translates a function from callable as f(a, b, c) into callable as f(a)(b)(c). You can learn more about currying and Ramda from our free Learn RamdaJS book.

We wrapped the definition of matches inside the curry function from Ramda as shown below:

1const matches = curry((pattern, object) => {
2  //...matches function logic
3});

With this update the findBy function could be simplified to:

1const findBy = (pattern, array) => array.find(matches(pattern));

We also used curry wrapping for findBy function for the same reason:

1const findBy = curry((pattern, array) => array.find(matches(pattern)));

Similar to findBy we also introduced the following functions to simplify development:

  • findIndexBy(pattern, data): finds the first index of occurrence of an item that matches the pattern from the given array.
  • filterBy(pattern, data): returns the filtered array of items based on pattern matching.
  • findLastBy(pattern, data): finds the last item that matches the given pattern.
  • removeBy(pattern, data): removes all items that match the given pattern from an array of items.
  • countBy(pattern, data): returns the number of items that match the given pattern.
  • replaceBy(pattern, newItem, data): replaces all items that match the given pattern with the given item.

Here are some example usages of these functions:

1findIndexBy({ name: "Sam" }, users);
2//returns the array index of Sam in "users"
3
4filterBy({ address: { street: "First street" } }, users);
5//returns a list of "users" who lives on First street
6
7removeBy({ name: "Sam" }, users); // removes Sam from "users"
8
9countBy({ age: 20 }, users);
10// returns the count of "users" who are exactly 20 years old.
11
12findLastBy({ name: includes("e") }, users);
13// returns the last user whose name contains the character 'e', from the array.
14
15const newItem = { id: 2, name: "John" };
16replaceBy({ name: "Sam" }, newItem, users);
17/*
18[
19  { id: 2, name: "John" },
20  { id: 2, name: "Oliver", age: 40,
21  //... Oliver's address attributes },
22];
23*/

*ById functions

Applications frequently rely on unique IDs for data retrieval. As a result, when using By functions, pattern matching for the ID becomes necessary.

1const defaultUser = findBy({ id: DEFAULT_USER_ID }, users);

To shorten this code, we developed a set of utility functions that can be invoked directly based on the ID. Let us call them ById functions. With ById functions, we can rewrite the previous code as:

1const defaultUser = findById(DEFAULT_USER_ID, users);

Here are some of the ById functions we use:

  • findById(id, data): finds an object having the given id from an array.
  • replaceById(id, newItem, data): returns a new array with the item having the given id replaced with the given object.
  • modifyById(id, modifier, data): applies a modifier function to the item in an array that matches the given id. It then returns a new array where the return value of the modifier function is placed in the index of the matching item.
  • findIndexById(id, data): finds the index of an item from an array of items based on the id provided.
  • removeById(id, data): returns a new array where the item with the given id is removed.

Here are a few examples:

1findById(2, users); // returns the object with id=2 from "users"
2
3const idOfItemToBeReplaced = 2;
4const newItem = { id: 3, name: "John" };
5replaceById(idOfItemToBeReplaced, newItem, users);
6//[ { id: 1, name: "Sam", age:20, ...}, { id: 3, name: "John" }]
7
8const idOfItemToBeModified = 2;
9const modifier = item => assoc("name", item.name.toUpperCase(), item);
10modifyById(idOfItemToBeModified, modifier, users);
11//[{ id: 1, name: "Sam", ... }, { id: 2, name: "OLIVER", ... }]
12
13const idOfItemToBeRemoved = 2;
14removeById(idOfItemToBeRemoved, users);
15// [{ id: 1, name: "Sam", ... }]

assoc is a function from Ramda that makes a shallow clone of an object, setting or overriding the specified property with the given value.

Null-safe alternatives for pure functions

The By and ById functions proved to be invaluable to us in improving the code quality. However, when working with data in web applications, it is quite common to come across scenarios where the data being processed can be null/undefined. The above-mentioned implementations of the By and ById functions will fail with an error if the users array passed into them is null/undefined.

In such a case, to use the filterBy function, we need to adopt a method like this:

1users && filterBy({ age: 20 }, users);

So we needed a fail-safe alternative that could be used in places where the data array can be null/undefined. This null-safe alternative should avoid execution & return users if users is null/undefined. It should work the same as filterBy otherwise.

Hence we created a wrapper function that would check for data nullity and execute the child function conditionally. This is how we did it:

1const nullSafe =
2  func =>
3  (...args) => {
4    const dataArg = args[func.length - 1];
5
6    return isNil(dataArg) ? dataArg : func(...args);
7  };

With the help of this nullSafe function, we created null-safe alternatives for all our pure functions.

1const _replaceById = nullSafe(replaceById);
2const _modifyById = nullSafe(modifyById);

But with the nullSafe wrapping, currying ceased to work for these null-safe alternative functions. To retain currying, we had to rewrite nullSafe using the curryN function from Ramda like this:

1const nullSafe = func =>
2  curryN(func.length, (...args) => {
3    const dataArg = args[func.length - 1];
4    return isNil(dataArg) ? dataArg : func(...args);
5  });

Some other useful functions

keysToCamelCase

Recursively converts the snake-cased object keys to camel case.

1const snakeToCamelCase = string =>
2  string.replace(/(_\w)/g, letter => letter[1].toUpperCase());
3
4const keysToCamelCase = obj =>
5  Object.fromEntries(
6    Object.entries(obj).map(([key, value]) => [
7      snakeToCamelCase(key),
8      typeof value === "object" && value !== null && !Array.isArray(value)
9        ? keysToCamelCase(value)
10        : value,
11    ])
12  );
13
14keysToCamelCase({
15  first_name: "Oliver",
16  last_name: "Smith",
17  address: { city: "Miami", phone_number: "389791382" },
18});
19/*
20{ address: {city: 'Miami', phoneNumber: '389791382'},
21  firstName: "Oliver", lastName: "Smith",
22}
23*/

isNot

Returns true if the given values (or references) are not equal. false otherwise.

1const isNot = curry((x, y) => x !== y);

Say, you have a task at hand - finding details about users, but specifically excluding the user named "Sam". In such a scenario, you can retrieve the information as shown below:

1filterBy({ name: name => name != "Sam" }, users);

But this could be made more readable and concise if we have a function that finds the non-identical matches from users list. For this, you can use the isNot function.

1filterBy({ name: isNot("Sam") }, users);
2// returns an array of all users except "Sam"

Stay up to date with our blogs. Sign up for our newsletter.

We write about Ruby on Rails, ReactJS, React Native, remote work,open source, engineering & design.