If you're using Lodash, you have a handful of tools at your disposal for organizing logic into functions. This is a nice alternative to imperative
if statements sprinkled throughout your code. For example, let's say that you only want to execute code if one of several choices is true. You could use the
some() function as follows:
const choices = [
{ name: 'choice 1', value: true },
{ name: 'choice 2', value: true },
{ name: 'choice 3', value: false }
];
if (_(choices).map('value').some()) {
console.log('true');
else {
console.log('false');
}
The
choices array represents the choices that we have available. The
name property isn't actually used for anything in this code. The
value property is what we're interested in here. We want to run some code if any value is true.
To do so, we're using an
if statement. With the help of the
map() and
some() Lodash functions, we can easily check for this condition. In this case, the statement evaluates to true because there's two true values in the array. We can also check to make sure that every value is true before executing a piece of code:
if (_(choices).map('value').every()) {
console.log('true');
else {
console.log('false');
}
In this case, the
else path is followed because not every value is true.
The _.
some() and _.
every() functions are helpful with simplifying the conditions evaluated by
if statements. For example, _.
some() provides the same result as chaining together a bunch of logical or (
||) operators. Likewise, _.
every() replaces logical and (
&&) operators.
The result is that instead of having to maintain the condition that's evaluated in the
if statement, we can simply add new values to the
choices collection. Essentially, this is a step toward declarative programming, away from imperative programming.
Let's think about the
if statement used above, and what it's actually doing. It's calling
console.log() when some condition is true, and it's calling
console.log() again when the condition is false. The problem with
if statements like this is that they're not very portable. It'd be much easier to call a function with the possible choices as an argument, and the correct behavior is invoked.
Here's what this might look like:
const some = (yes, no) => (...values) =>
new Map([
[true, yes],
[false, no]
]).get(_.some(values))();
Let's break this code down:
- We've created a higher-order function called some() that returns a new function.
- The returned function accepts an arbitrary number of values. These are tested with Lodash's _.some().
- The some() function accepts yes() and no() functions to run based on the result of calling _.some(values)
- A Map is used in place of an if statement to call the appropriate logging function.
With this utility, we can now compose our own functions that values as arguments, and based on those arguments, run the appropriate function. Let's compose a function using
some():
const hasSome = some(
() => console.log('has some'),
() => console.log('nope')
);
Now we have a
hasSome() function will log either
"has some" or
"nope", depending on what values are passed to it:
hasSome(0, 0, 0, 1, 0);
// -> has some
hasSome(0, 0, 0, 0);
// -> nope
Now any time that you want an
if statement that evaluates simple boolean expressions and runs one piece of code or another, depending on the result, you can use
some() to compose a new function. You then call this new function with the choices as the arguments.
Let's create an
every() function now that works the same way as
some() except that it tests that every value is true:
const every = (yes, no) => (...values) =>
new Map([
[true, yes],
[false, no]
]).get(_.every(values))();
The only difference between
every() and
some() is that we're using
_.every() instead of
_.some(). The approach is identical: supply
yes() and
no() functions to call depending on result of
_.every().
Now we can compose a
hasEvery() function, just like we did with the
hasSome() function:
const hasEvery = every(
() => console.log('has every'),
() => console.log('nope')
);
Once again, we've avoided imperative
if statements in favor of functions. Now we can call
hasEvery() from anywhere, and pass in some values to check:
hasEvery(1, 1, 1, 1, 1)
// -> has every
hasEvery(1, 1, 1, 1, 0)
// -> nope
Lodash has a
_.cond() function that works similarly to our
Map approach in
some() and
every(), only more powerful. Instead of mapping static values, such as true and false, to functions to run, it maps functions to functions. This allows you to compute values to test on-the-fly.
Before we get too fancy, let's rewrite our
some() and
every() functions using
_.cond():
const some = (yes, no) => _.flow(
_.rest(_.some, 0),
_.cond([
[_.partial(_.eq, true), yes],
[_.stubTrue, no]
])
);
Let's break this down:
- The _.flow() function creates a new function by calling the first function, then passing it's return value to the next function, and so on.
- The _.rest() function creates a new function that passes argument values as an array to it's wrapped function. We're doing this with _.some() because it expects an array, but we just want to be able to pass it argument values instead.
- The _.cond() function takes an array of pairs. A pair is a condition function, and a function to call if the condition function returns true. The first pair that evaluates to true is run, and no other pairs are evaluated.
- The _.partial(_.eq, true) call makes a new function that tests the output of _.some().
- The _.stubTrue() function will always evaluate to true, unless something above it evaluates to true first. Think of this as the else in an if statement.
We can use this new implementation of
some() to compose the same
hasSome() function that we created earlier and it will work the same way. Likewise, we can implement the
every() function using the same approach.
For something as simple as the
some() and
every() functions, the
_.cond() approach doesn't present any clear advantage over the
Map approach. This is because there are exactly two paths. Either the condition evaluates to true, or it doesn't. Often, we're not working with simple yes/no logical conditions. Rather, there are a number of potential paths.
Think of this as a an
if-
else statement with lots of conditions. Suppose we had the following conditions:
const condition1 = false;
const condition2 = true;
const condition3 = false;
Instead of a simple yes/no question with two potential paths, now we have 3. Later on, we might have 4, and so on. This is how software grows to be complex. Here's how we would evaluate these conditions and execute corresponding code using
_.cond():
const doStuff = _.cond([
[_.constant(condition1), () => console.log('Condition 1')],
[_.constant(condition2), () => console.log('Condition 2')],
[_.constant(condition3), () => console.log('Condition 3')]
]);
doStuff();
// -> Condition 2
For each of the condition constants that we defined above, we're using the
_.constant() function in
_.cond(). This creates a function that just returns the argument that is passed to it. As you can see,
console.log('Condition 2') is called because the function returned by
_.constant(condition2) returns true.
It's easy to add new pairs to
_.cond() as the need arises. You can have 20 different execution paths, and it's just as easy to maintain as 2 paths.
In this example, we're using static values as our conditions. This means that
doStuff() will always follow the same path, which kind of defeats the purpose of this type of code. Instead, we want the path chosen by
_.cond() to reflect the current state of the app:
const app = {
condition1: false,
condition2: false,
condition3: true
};
Instead of using
_.const(), we'll have to somehow pass the app into each evaluator function in
_.cond():
const doStuff = _.cond([
[_.property('condition1'), () => console.log('Condition 1')],
[_.property('condition2'), () => console.log('Condition 2')],
[_.property('condition3'), () => console.log('Condition 3')]
]);
The
_.property() function creates a new function that returns the given property value of an argument. This is where the
_.cond() approach really shines: we can pass arguments to the function that it creates. Here, we want to pass it the
app object so that we can process its state:
doStuff(app);
// -> Condition 3
app.condition1 = true;
app.condition3 = false;
doStuff(app);
// -> Condition 1
When
doStuff() is called the first time, the
console.log('Condition 3') path is executed. Then, we change the state of
app so that
condition1 is true and
condition3 is false. When
doStuff() is called again with
app as the argument, the
console.log('Condition 1') path is executed.
So far, we've been composing functions that use
console.log() to print values. If you write smaller functions that return values instead of simply printing them, you can combine them to build more complex logic. Think of this as an alternative to implementing nested
if statements.
As an example, suppose we have the following two functions:
const cond1 = _.cond([
[_.partial(_.eq, 1), _.constant('got 1')],
[_.partial(_.eq, 2), _.constant('got 2')]
]);
const cond2 = _.cond([
[_.partial(_.eq, 'one'), _.constant('got one')],
[_.partial(_.eq, 'two'), _.constant('got two')]
]);
These functions themselves follow the same implementation approach, using
_.cond(). For example,
cond1() will return the string
'got 1' or
'got 2', depending on the number supplied as an argument. Likewise,
cond2() will return
'got one' or
'got two', depending on the string argument value.
While we can use both of these functions on their own, we can also use them to compose another function. For example, we could write an
if statement that would determine which one of these functions to call:
if (_.isFinite(val)) {
cond1(val);
} else if (_.isString(val)) {
cond2(val);
}
Remember, this approach isn't very portable. To make it portable, in the sense that we don't have to write the same
if statement all over the place, we could wrap the whole thing in a function. Or, we could just use
_.cond() to compose it:
const cond3 = _.cond([
[_.isFinite, cond1],
[_.isString, cond2]
]);
cond3(1);
// -> "got 1"
cond3(2);
// -> "got 2"
cond3('one');
// -> "got one"
cond3('two');
// -> "got two"
Using
_.cond(), you can compose complex logic by reusing existing functions. This means that you can keep using these smaller functions where they're needed, and you can use them as pieces of larger functions.