Extending pure utility functions of Ramda.js

Neenu Chacko

Neenu Chacko

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:

const userOrder = {
  id: 12356,
  user: {
    id: 2345,
    name: "John Smith",
    role: "customer",
    type: "standard",
    email: "[email protected]",
  },
  amount: 25000,
  type: "prepaid",
  status: "dispatched",
  shipTo: {
    name: "Bob Brown",
    address: "456 Oak Lane",
    city: "Pretendville",
    state: "Oregon",
    zip: "98999",
  },
};

We can check if this order is deliverable like this.

const isDeliverable =
  userOrder.type === "prepaid" &&
  useOrder.user.role === "customer" &&
  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:

const isDeliverable = matches(DELIVERABLE_ORDER_PATTERN, userOrder);

Here the DELIVERABLE_ORDER_PATTERN is defined as:

const DELIVERABLE_ORDER_PATTERN = {
  type: "prepaid",
  status: "dispatched",
  user: { role: "customer" },
};

This is how we implemented the matches function:

const matches = (pattern, object) => {
  if (object === pattern) return true;

  if (isNil(pattern) || isNil(object)) return false;

  if (typeof pattern !== "object") return false;

  return Object.entries(pattern).every(([key, value]) =>
    matches(value, object[key])
  );
};

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.

const isToBeShippedToMichiganOrOregon =
  ["Michigan", "Oregon"].includes(userOrder.shipTo.state) &&
  // 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.

matches(
  {
    shipTo: { state: state => ["Michigan", "Oregon"].includes(state) },
    // ...other properties
  },
  userOrder
);

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

const matches = (pattern, object) => {
  if (object === pattern) return true;

  if (typeof pattern === "function" && pattern(object)) return true;

  if (isNil(pattern) || isNil(object)) return false;

  if (typeof pattern !== "object") return false;

  return Object.entries(pattern).every(([key, value]) =>
    matches(value, object[key])
  );
};

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

const user = {
  firstName: "Oliver",
  address: { city: "Miami", phoneNumber: "389791382" },
  cars: [{ brand: "Ford" }, { brand: "Honda" }],
};

matches({ cars: includes({ brand: "Ford" }) }, user); //true
matches({ 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:

const users = [
  {
    id: 1,
    name: "Sam",
    age: 20,
    address: {
      street: "First street",
      pin: 123456,
      contact: {
        phone: "123-456-7890",
        email: "[email protected]",
      },
    },
  },
  {
    id: 2,
    name: "Oliver",
    age: 40,
    address: {
      street: "Second street",
      pin: 654321,
      contact: {
        phone: "987-654-3210",
        email: "[email protected]",
      },
    },
  },
];

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

const 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:

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

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

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

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

findBy({ age: 40, address: { pin: 654321 } }, users);
// returns details of the first user with age 40 whose pin is 654321
findBy({ address: { contact: { email: "[email protected]" } } }, users);
// returns details of the first user whose contact email is "[email protected]"

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:

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

With this update the findBy function could be simplified to:

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

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

const 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:

findIndexBy({ name: "Sam" }, users);
//returns the array index of Sam in "users"

filterBy({ address: { street: "First street" } }, users);
//returns a list of "users" who lives on First street

removeBy({ name: "Sam" }, users); // removes Sam from "users"

countBy({ age: 20 }, users);
// returns the count of "users" who are exactly 20 years old.

findLastBy({ name: includes("e") }, users);
// returns the last user whose name contains the character 'e', from the array.

const newItem = { id: 2, name: "John" };
replaceBy({ name: "Sam" }, newItem, users);
/*
[
  { id: 2, name: "John" },
  { id: 2, name: "Oliver", age: 40,
  //... Oliver's address attributes },
];
*/

*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.

const 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:

const 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:

findById(2, users); // returns the object with id=2 from "users"

const idOfItemToBeReplaced = 2;
const newItem = { id: 3, name: "John" };
replaceById(idOfItemToBeReplaced, newItem, users);
//[ { id: 1, name: "Sam", age:20, ...}, { id: 3, name: "John" }]

const idOfItemToBeModified = 2;
const modifier = item => assoc("name", item.name.toUpperCase(), item);
modifyById(idOfItemToBeModified, modifier, users);
//[{ id: 1, name: "Sam", ... }, { id: 2, name: "OLIVER", ... }]

const idOfItemToBeRemoved = 2;
removeById(idOfItemToBeRemoved, users);
// [{ 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:

users && 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:

const nullSafe =
  func =>
  (...args) => {
    const dataArg = args[func.length - 1];

    return isNil(dataArg) ? dataArg : func(...args);
  };

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

const _replaceById = nullSafe(replaceById);
const _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:

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

Some other useful functions

keysToCamelCase

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

const snakeToCamelCase = string =>
  string.replace(/(_\w)/g, letter => letter[1].toUpperCase());

const keysToCamelCase = obj =>
  Object.fromEntries(
    Object.entries(obj).map(([key, value]) => [
      snakeToCamelCase(key),
      typeof value === "object" && value !== null && !Array.isArray(value)
        ? keysToCamelCase(value)
        : value,
    ])
  );

keysToCamelCase({
  first_name: "Oliver",
  last_name: "Smith",
  address: { city: "Miami", phone_number: "389791382" },
});
/*
{ address: {city: 'Miami', phoneNumber: '389791382'},
  firstName: "Oliver", lastName: "Smith",
}
*/

isNot

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

const 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:

filterBy({ 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.

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

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.