Many years ago, I took part in the development of a taxi-hailing mobile app that is still widely used today. I don’t know what kind of code they’re running now, but in those early days, the driver assignment code –if I remember it correctly– was similar in spirit to the grossly simplified example shown below:
async function assignDriver(rider, availableDrivers) {
const driverDistances = await calculateDistances(rider.location, availableDrivers);
let assignedDriver = null;
for (let driver of availableDrivers) {
if (driverDistances[driver.id] <= 5) {
if (!rider.preferredVehicle || rider.preferredVehicle === driver.vehicle) {
if (driver.rating >= 4.5) {
if (rider.preferences.includes('Premium Driver')) {
if (driver.isPremiumDriver) {
assignedDriver = driver;
break;
} else {
continue;
}
} else {
assignedDriver = driver;
break;
}
} else if (driver.rating >= 4.0) {
assignedDriver = driver;
break;
}
}
}
}
return assignedDriver;
}
There are five levels of nested if statements in less than 30 lines of code. It doesn’t look so bad, some might say, but it’s not difficult to imagine how complicated this code can become with just a few more checks, such as surge pricing and loyalty programs, among other things.
Fortunately, there are ways to flatten code, and you might be surprised by the end result as there will be no if statements left when we are finished refactoring it.
Early Returns with Guard Clauses
Let’s start with the easy pickings. The very first if statement, which checks the maximum allowed distance between a driver and the rider, can be converted into a guard clause to remove one level of nesting since it applies to all cases. We can similarly add one more guard clause for the preferred vehicle check to remove an additional level of nesting. The resulting code is shown below:
async function assignDriver(rider, availableDrivers) {
const driverDistances = await calculateDistances(rider.location, availableDrivers);
let assignedDriver = null;
for (let driver of availableDrivers) {
if (driverDistances[driver.id] > 5) {
continue;
}
if (rider.preferredVehicle && rider.preferredVehicle !== driver.vehicle) {
continue;
}
if (driver.rating >= 4.5) {
if (rider.preferences.includes('Premium Driver')) {
if (driver.isPremiumDriver) {
assignedDriver = driver;
break;
} else {
continue;
}
} else {
assignedDriver = driver;
break;
}
} else if (driver.rating >= 4.0) {
assignedDriver = driver;
break;
}
}
return assignedDriver;
}
Decision Tables
Instead of hardcoding the logic inside our function, we can place each if-else block in its own function and put those functions in an array, forming a decision table. Then, we run each function in the decision table until we get a positive response. Obviously, the entries in the table must be sorted from most specific to least specific for it to function properly in our case.
const conditions = [
(rider, driver) => driver.rating >= 4.5 && rider.preferences.includes('Premium Driver') && driver.isPremiumDriver,
(rider, driver) => driver.rating >= 4.5 && !rider.preferences.includes('Premium Driver'),
(rider, driver) => driver.rating >= 4.0 && driver.rating < 4.5,
];
async function assignDriver(rider, availableDrivers, conditions) {
const driverDistances = await calculateDistances(rider.location, availableDrivers);
let assignedDriver = null;
for (let driver of availableDrivers) {
if (driverDistances[driver.id] > 5) {
continue;
}
if (rider.preferredVehicle && rider.preferredVehicle !== driver.vehicle) {
continue;
}
if (conditions.find((condition) => condition(rider, driver))) {
assignedDriver = driver;
break;
}
}
return assignedDriver;
}
With the addition of a decision table, we have completely eliminated nested ifs, and as an added bonus, the driver assignment logic can now be changed by simply editing the conditions array.
Function Composition
Let’s get rid of the remaining if statements by converting the for loop to Array.find() and creating individual functions for each if statement inside the loop:
const conditions = [
(rider, driver) => driver.rating >= 4.5 && rider.preferences.includes('Premium Driver') && driver.isPremiumDriver,
(rider, driver) => driver.rating >= 4.5 && !rider.preferences.includes('Premium Driver'),
(rider, driver) => driver.rating >= 4.0 && driver.rating < 4.5,
];
async function assignDriver(rider, availableDrivers, conditions) {
const driverDistances = await calculateDistances(rider.location, availableDrivers);
const isDriverClose = (driver) => driverDistances[driver.id] <= 5;
const isVehicleOK = (rider, driver) => !rider.preferredVehicle || rider.preferredVehicle === driver.vehicle;
const isAConditionSatisfied = (rider, driver) => conditions.find((condition) => condition(rider, driver));
const assignedDriver = availableDrivers.find(
(driver) => isDriverClose(driver) && isVehicleOK(rider, driver) && isAConditionSatisfied(rider, driver)
);
return assignedDriver || null;
}
Summary
By utilizing basic functional programming principles, we have:
- Removed all nested if statements,
- Decoupled most of the logic and made it easily modifiable,
- Made the code easier to follow by placing individual if-else blocks in their own functions,
- Finally, as a nice side effect, cut the program size by one-third.
Discussed on:
Related: