Skip to main content

Killing "IF" softly

· 11 min read

There is a ton of material out there about the negative affects of nested if-else statements. Most of the time the problem description is accompanied by the refactoring tips and trick to make the code more readable, flat, compact, etc. With this article, we are going to explore a different approaches of dealing with the conditional "if" operator. We'll try to eliminate the "if" statements from the code usage, yet keeping all the benefits of conditional branching.

The problem

As mentioned above, the problem itself is very well known. Uncontrolled application of the conditional "if-else" statements leads to the code the looks like this:

if
if
if // \
if // ------\
else // ------/
else // /
else
else

Some of the methods of "if-else" extraction require flexible enough programming language syntax. For that particular reason, JavaScript will be used in all the examples presented.

Let's start with the basic example. Here we have a list of bank accounts and we are trying to understand which one is eligible for a promotion.

const accounts = [{
active: true,
balance: 123000,
created: new Date('2010-12-14')
}, {
active: false,
balance: 0,
created: new Date('1999-03-17')
},{
active: true,
balance: 200,
created: new Date('2011-10-01')
}];

const account = accounts[Math.floor(Math.random() * 3)];

const eligibleForPromotion = (account) => {
if(account.active) {
if(account.created.getFullYear() > 2000) {
if(account.balance > 100000) {
console.log('eligible for promotion');
} else {
console.log('not eligible');
}
} else {
console.log('legacy account');
}
} else {
console.log('inactive');
}
}

Flattening

First step is fairly straightforward. We start with removing nested conditions by introducing terminating early statements. We also changed console log statements for the explicit return type. At the moment this helps supporting eligibleForPromotion method with tests and, as a result easier refactoring experience.

const eligibleForPromotion = (account) => {
if(!account.active) {
return {
success: false,
reason: 'inactive'
};
}

if(account.created.getFullYear() <= 2000) {
return {
success: false,
reason: 'legacy account'
};
}

if(account.balance > 100000) {
return {
success: true,
reason: 'eligible for promotion'
};
}

return {
success: false,
reason: 'not eligible'
};
}

Decomposition

Flattening helps us start thinking in terms of the next step - splitting a problem into simpler/smaller sub-problems decomposition. In this context, every "if" statement becomes a separate method that returns the same result as before when the condition under test was met and null overwise. There is only one exception to this rule. The eligible account condition itself returns non null result in any case.

We could have implemented this refactoring in a way the all the sub-problem methods return a specific type instance or a null, but for the sake of more laconic explanation we are going to leave that for the reader.

All the methods are put together in a array in the exact same order they're present in the code before refactoring. Last touch, we just iterate over the array to find the first element where non null condition is satisfied, which wil gives us the answer for the account specified.

const active = (account) => account.active ?
null : { success: false, reason: 'inactive' };

const year = (account) => account.created.getFullYear() > 2000 ?
null : { success: false, reason: 'legacy account' };

const balance = (account) => account.balance > 100000 ?
{ success: true, reason: 'eligible for promotion' } :
{ success: false, reason: 'not eligible' };

const eligibleForPromotion = (account) => {
let result;
[active, year, balance]
.find(action => { result = action(account); return result != null});
return result;
}

In case you are not familiar with the idea of using an array of functions and choosing an appropriate element out of it, here is a little detour

Strategy pattern

Strategy pattern gives us the ability to push a selection of the algorithm to the runtime program execution. The idea becomes more clearer when we think of a conditional "if" operators as a construct of three independent parts:

  • expression under test - language expression evaluated to an instance of a boolean type
  • truthy brach execution action - an action performed when the expression under test evaluates to true
  • falsy brach execution action - an action performed when the expression under test evaluates to false
if(/* expression under test */) {
// truthy brach execution action
} else {
// falsy brach execution action
}

So with the strategy pattern we are using those three parts independently. Effectively, we are changing control flow from a series of "if" statements to a sequence of actions (truthy or falsy) that are selected by traversing/searching a sequence of boolean expressions or a sequence of literals to match by (usually exactly). As an example, we are using a JavaScript object as a hash table to select the execution action in one step:

const colors = {
'red': () => console.log('red'),
'green': () => console.log('green'),
'blue': () => console.log('blue')
}

const color = 'green';
colors[color]();

If this example looks suspiciously similar to how "switch" operator works, that is not a coincidence.

Boolean function

So far, we've been mostly focusing on the execution branches of the "if" operator. But what about the expression under test itself? Even with the strategy pattern, it was always there. It was just taking different forms. First, it was a predicate provided to the array find method. Second, it was a hash table key matching step for a JavaScript object. But what if we start thinking about a boolean expression as of something that could be composed as well. Let's start with what we already know - the Boolean object. As the docs describe, it's a wrapper on top of the boolean primitive. As a full blown JavaScript object, it has a constructor that takes a boolean primitive and returns an instance of a Boolean type. But it could also be used as a function that takes a primitive boolean and return primitive boolean. So we could use the same idea and convert our booleans into wrappers that we could use in a much more composable way.

During the "if" operator execution, when the expression under test is true, one of the branches of the operator is selected. Let's call it the left one. Using the same analogy, when the expression evaluates to false - right execution branch is selected. So we can create two functions True and False to mimic this behavior and map them back to the primitive booleans:

const True = (left, _) => left
const False = (_, right) => right
const Boolean = {
true: True,
false: False
};

console.log(Boolean[(1 + 1 > 1)](true, false));

The true boolean parameter represents the left branch and false represents the right one. Well, there is no much use of the newly created boolean constructs if we cannot perform boolean operations against them. We need a way to represent Boolean algebra operations in a form of functions taking True and False as parameters yet keeping the left - right branching execution strategy in place.

const And = (x, y) => x(y, False)  // inclusive or conjunction
const Or = (x, y) => x(True, y) // exclusive or disjunction

console.log(And(False, True)(true, false)) // false
console.log(And(True, False)(true, false)) // false
console.log(Or(False, True)(true, false)) // true
console.log(Or(True, False)(true, false)) // true

And function returns True only when both parameters are True. Or function returns False only when both parameters are False. Here is the execution example step by step:

And(False, True) => False(True, False) => False => False(true, false) => false
And(True, False) => True(False, False) => False => False(true, false) => false
Or(False, True) => False(True, True) => True => True(true, false) => true
Or(True, False) => True(True, False) => True => True(true, false) => true

Here is what happens when we put things together:

Or(Boolean[1 + 1 > 1], Boolean[1 > 2])(
() => console.log('left execution branch'),
() => console.log('right execution branch')
)()

So instead of using primitive booleans as final parameters, we can use functions as execution branches:

And(Boolean[account.active], True)(
And(Boolean[account.created.getFullYear() > 2000], True)(
And(Boolean[account.balance > 100000], True)(
() => console.log('eligible for discount'),
() => console.log('not eligible')
),
() => console.log('legacy account')
),
() => console.log('inactive')
)()

This code might look like we are going back to where we started. But that is not entirely correct. Yes, we changed if - else branching for an And - lambda branching sort of speak. Yes, we are console logging instead of returning typed results. But we are more flexible now:

const active = (action) => And(Boolean[account.active], True)(
action, () => console.log('inactive')
)
const year = (action) => And(Boolean[account.created.getFullYear() > 2000], True)(
action,
() => console.log('legacy account')
)

const balance = () => And(Boolean[account.balance > 100000], True)(
() => console.log('eligible for discount'),
() => console.log('not eligible')
)

active(year(balance()))()

Either this or that

Ok, we had some ups and downs so far. By gaining more compositionality we lost some semantics. But the next step is definitely clear. Results of all the nested computations need to be populated all the way back to the caller, so the caller can/should make a decision on how to deal with them.

Let's encapsulate all the ideas we had so far into one wrapper:

const either = (_value, _isLeft, _isRight) => ({
get value() {
return _value
},

get isLeft () {
return _isLeft;
},

get isRight () {
return _isRight;
},

map: undefined,

chain: undefined,

});

const left = (value) => ({
...either(value, true, false),
map() {
return this;
},
chain() {
return this;
}
});

const right = (value) => ({
...either(value, false, true),
map(action) {
return right(action(value));
},
chain(action) {
return action(value);
}
});

either becomes the base of our branching logic, where left and right are the branches themselves.

left branch is used to populate results back, while right one is used to drill down the computation. Again, this is not a coincidence.

Here is what we can do with these constructs now:

const result = right(account)
.map(account =>
account.active ?
right(account) :
left({ success: false, reason: 'inactive' })
)
.map(item =>
item.map(value => value.created.getFullYear() > 2000 ?
right(value) :
left({ success: false, reason: 'legacy account' })))
.map(item =>
item.map(item => item.map(value =>
value.balance > 100000 ?
right({ success: true, reason: 'eligible for promotion' }) :
left({ success: false, reason: 'not eligible' })
)));

console.log(result.value?.value?.value?.value)

Well, we got the results back now, but the format preserves the nesting structure of the computational levels. Let's use chain to flatten the tree (flattening again!):

const result = right(account)
.chain(account =>
account.active ?
right(account) :
left({ success: false, reason: 'inactive' })
)
.chain(item => item.created.getFullYear() > 2000 ?
right(item) :
left({ success: false, reason: 'legacy account' }))
.chain(item =>
item.balance > 100000 ?
right({ success: true, reason: 'eligible for promotion' }) :
left({ success: false, reason: 'not eligible' })
);

console.log(result.value)

And finally, let's put a big And at the end of this story:

const result = right(account)
.chain(item =>
// we can make more complex logical expressions
And(Boolean[item.active],
Boolean[new Date().getDate() > 15])(
right(item),
left({ success: false, reason: 'inactive' })
))
.chain(item => And(Boolean[item.created.getFullYear() > 2000], True)(
right(item),
left({ success: false, reason: 'legacy account' })
))
.chain(item => And(Boolean[item.balance > 100000], True)(
right({ success: true, reason: 'eligible for promotion' }),
left({ success: false, reason: 'not eligible' })
));

console.log(result.value)

Conclusion

We managed to gradually remove things like &&, ||, if, else from the source code. Does this new code look better or worse? Well, that is subjective. But it is more linear, composable and testable. Not necessarily it is more understandable, because that depends on a reader and is subjective as well.

Source code

Reference implementation

References

Decomposition

Strategy pattern

Boolean object

Boolean algebra

Weird Booleans in JavaScript

Mostly adequate guide to FP (Algebraic Structures Support)