/ programming

Stop Writing If Statements

An if statement is probably the most primitive control flow instruction, shared by all programming languages. It's a simple control structure to split logic into two mutually exclusive paths.

It's also one of the most non-intuitive and non-obvious contributors to code smell. Used permissively, if statements (often unintentionally) encourage poor practices for readibility, style, and complexity of logic.

And here's why. if statements:

  • destroy linearity of logic within a procedure
  • implicitly create a nested scope within the same semantic context
  • encourage imperative, mutating logic over declarative logic

I'll provide a take on each of these.

Linearity of logic

Linear, non-branching logic paths within a single scope (procedure) go hand-in-hand with readibility. If your code can only follow a single path from start to finish, it's:

  • easier to follow as a reader (no need to mentally context switch into different scenarios halfway through a computation),
  • easier to test (full branch coverage if there are no branches to begin with), and
  • less prone to bugs (fewer edge cases).
function doStuffWithWords() {
  const words = [];

  words.push('always');

  if (cond1()) {
    words.push('cond1');
  } else {
    words.push('else-cond1');
  }

  if (cond2()) {
    words.push('cond2');
  }

  return words;
}

While the code is simple, it's obvious that readibility suffers. Imagine a similar control flow structure in a production codebase, with each block invoking several function calls and declaring its own scoped variables. You almost need to draw a state transition diagram to trace the logic through from top to bottom, and determine every possible outcome from each combination of path divergences introduced by the tangle of if statements.

Consider instead:

function doStuffWithWords() {
  return [
    'always',
    cond1() ? 'cond1' : 'else-cond1',
    cond2() && 'cond2',
  ].filter(Boolean);
}

This is much simpler, and makes it easier to visualize the structure of the output just by grokking the structure of the logic. Though the logic itself still branches, the structure of the logic is linear (and, as an added bonus, also declarative).

One of the most convenient uses of the logical 'or' (||) operator is specifying a default fallback case during assignment, which eliminates the necessity of an if block (logic branch) to handle the case where the original value is falsey. This simplifies logic and linearizes its structure.

let port = getPort();

if (!port) {
  port = 3000;
}

Instead:

// The second half is not evaluated if getPort() returns a truthy value anyway
const port = getPort() || 3000;

Nested scopes

if statements in this case lend itself to two more code smells: usage of mutable data strutures (think let) and declaration of new variables within an inner scope.

let greeting;

if (isFancy) {
  const { name, title } = someReallyComplicatedProcedure();
  greeting = `Hello ${title} ${name}`;
}

console.log(greeting);
  1. greeting is declared in the outer scope but modified in the inner scope introduced by the if statement. Follow the logic linearly: after the initial declaration of greeting with the let statement, it's not clear how/when/where its value is actually assigned. Further, if logic doesn't go into the if block at all, greeting is left undefined!
  2. name and title are destructured within the scope of the if block. What if these values are required outside of that inner scope? If logic never enters that block, attempting to access those values will throw a ReferenceError!

The outer and inner scopes are in the same semantic context--we're just trying to create a greeting string. Consider instead:

const { name, title } = someReallyComplicatedProcedure();

const greeting = isFancy && `Hello ${title} ${name}`;

No more nested scopes, and guaranteed definition of name and title anywhere throughout the procedure!

Taking advantage of Javascript's type coercion lets us use && as a blocking 'guard' for subsequent logic. This is a convenient, declarative abstraction for maintaining branching logic without creating a nested scope. Similarly, the ternary operator (?) does this as well:

let greeting;

if (isFancy) {
  greeting = 'Salutations';
} else {
  greeting = 'Hello';
}

While we do no harm in declaring two separate, nested scopes (no additional declarations occur in either of the two blocks), we can still eliminate them entirely:

const greeting = isFancy ? 'Salutations' : 'Hello';

Imperative logic

if statements are inherently imperative. You are dictating the control flow to instruct the program how your data should be manipulated, rather than declaratively stating what the result of the computation should be.

ES6 provides syntax that makes it easy to refactor if statements out of your code. Take, for example, the spread operator:

const config = {
  path: '/mnt/code'
  port: 3000,
  title: 'dev',
}

// :(
if (isProduction) {
  config.port = 18200;
  config.title = 'prod';
}

Instead:

const config = {
  path: '/mnt/code'
  port: 3000,
  title: 'dev',
  ...isProduction && {
    port: 18200,
    title: 'prod',
  }
}

Alternatively:

const baseConfig = {
  path: '/mnt/code'
  port: 3000,
  title: 'dev',
};

const prodConfig = {
  ...baseConfig,
  port: 18200,
  title: 'prod',
};

const config = isProduction ? prodConfig : baseConfig;

Even if your environment does not support ES6 transpilation, there's no reason you can't write your own abstractions to make the logic more declarative:

// Merge the key-value pairs of obj2 into obj1
function merge(obj1, obj2) {
  return Object.keys(obj2 || {}).reduce((acc, key) => {
    acc[key] = obj2[key];
    return acc;
  }, obj1);
}

const baseConfig = {
  path: '/mnt/code'
  port: 3000,
  title: 'dev',
};

const prodConfigOverrides = {
  port: 18200,
  title: 'prod',
}

const config = merge(baseConfig, isProduction && prodConfigOverrides);

So, in the interest of making your frontend codebase more readable and having fewer coworkers yell at you in code reviews, please stop writing if statements in your code.