May 30, 2023
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.
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.
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.
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 },
];
*/
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.
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);
});
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",
}
*/
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.