Skip to content

Instantly share code, notes, and snippets.

@jonkemp
Last active April 10, 2024 14:44
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jonkemp/2c6e1e0f530b2af034a50374532f406f to your computer and use it in GitHub Desktop.
Save jonkemp/2c6e1e0f530b2af034a50374532f406f to your computer and use it in GitHub Desktop.
'Don’t use switch' excerpted from 'Programming JavaScript Applications' by Eric Elliott, https://www.oreilly.com/library/view/programming-javascript-applications/9781491950289/

Don't Use switch

JavaScript has pretty normal control-flow statements that use blocks delineated by curly braces. There is an exception to this: the switch ... case statement. The strange thing about switch ... case is that you must include the keyword break at the end of each case to prevent control from falling through to the next case. Fall through is a trick that allows you to let more than one case be executed. Control will fall through automatically to the next case unless you explicitly tell it not to with break. However, like the optional semicolons and curly braces, it's possible to forget break when you really should have used it. When that happens, the bug is difficult to find because the code looks correct. For that reason, the break statement should never be left off of a case, even by design.

With that said, JavaScript has an elegant object-literal syntax and first-class functions, which makes it simple to create a keyed method lookup. The object you create for your method lookup is called an action object or command object and is used in many software design patterns.

Say you're creating a game where the nonplayer fight actions are selected based on an algorithm defined elsewhere and passed in to doAction as a string. The switch ... case form looks like this:

function doAction(action) {
  switch (action) {
    case 'hack':
      return 'hack';
    break;
    
    case 'slash':
      return 'slash';
    break;
    
    case 'run':
      return 'run';
    break;
    
    default:
      throw new Error('Invalid action.');
    break;
  }
}

The method lookup version looks like this:

function doAction(action) {
  var actions = {
    'hack': function () {
      return 'hack';
    },

    'slash': function () {
      return 'slash';
    },

    'run': function () {
      return 'run';
    }
  };

  if (typeof actions[action] !== 'function') {
    throw new Error('Invalid action.');
  }
  
  return actions[action]();
}

Or, for input grouping (a frequent use case for the fall-through feature): say you're writing a programming language parser, and you want to perform one action whenever you encounter a token that opens an object or array, and another whenever you encounter a token that closes them. Assume the following functions exist:

function handleOpen(token) {
  return 'Open object / array.';
}

function handleClose(token) {
  return 'Close object / array';
}

The switch ... case form is:

function processToken (token) {
  switch (token) {
    case '{':
    case '[':
      handleOpen(token);
    break;
    
    case ']':
    case '}':
      handleClose(token);
    break;
    
    default:
      throw new Error('Invalid token.');
    break;
  }
}

The method lookup version looks like this:

var tokenActions = {
  '{': handleOpen,
  '[': handleOpen,
  ']': handleClose,
  '}': handleClose
 };

function processToken(token) {
  if (typeof tokenActions[token] !== 'function') {
    throw new Error('Invalid token.');
  }

  return tokenActions[token](token);
}

At first glance, it might seem like this is more complicated syntax, but it has a few advantages:

  • It uses the standard curly-bracket blocks used everywhere else in JavaScript.
  • You never have to worry about remembering the break.
  • Method lookup is much more flexible. Using an action object allows you to alter the cases dynamically at runtime, for example, to allow dynamically loaded modules to extend cases, or even swap out some or all of the cases for modal context switching.
  • Method lookup is object oriented by definition. With switch ... case, your code is more procedural.

The last point is perhaps the most important. The switch statement is a close relative of the goto statement, which computer scientists argued for 20 years to eradicate from modern programming languages. It has the same serious drawback: almost everywhere I've seen switch ... case used, I've seen it abused. Developers group unrelated functionality into overly-clever branching logic. In other words, switch ... case tends to encourage spaghetti code, while method lookup tends to encourage well-organized, object-oriented code. It's far too common to find implementations of switch ... case, which violate the principles of high cohesion and separation of concerns.

I was once a fan of switch ... case as a better alternative to if ... else, but after becoming more familiar with JavaScript, I naturally fell into using method lookup instead. I haven't used switch ... case in my code for several years. I don't miss it at all.

If you ever find yourself writing a switch statement, stop and ask yourself the following questions:

  • Will you ever need to add more cases? (queue, stack, plug-in architecture)
  • Would it be useful to modify the list of cases at runtime, for example, to change the list of enabled options based on context? (mode switching)
  • Would it be useful to log the cases that get executed, for example, to create an undo/redo stack, or log user actions to your servers for analysis? (command manager)
  • Are you referencing your cases by incrementing numbers, for example, case 1:, case: 2, etc.? (iterator target)
  • Are you trying to group related inputs together with the fall through feature so that they can share code?

If you answered yes to any of these questions, there is almost certainly a better implementation that doesn't utilize switch or its slippery fall-through feature.

@valsaven
Copy link

valsaven commented Sep 9, 2020

You don't need to write break after return.

So, it's just neat and clean:

function doAction(action) {
  switch (action) {
    case 'hack':
      return 'hack';    
    case 'slash':
      return 'slash';
    case 'run':
      return 'run';
    default:
      throw new Error('Invalid action.');
  }
}

@Serg-develop
Copy link

Serg-develop commented Jul 13, 2021

Just create an object:

actionTypes={ hack:"hack", slash:"slash", run:"run" }

and then call(for example)

const {action} = this.props; const currentAction=actionTypes[action]() || someDefaultFnc()

@SidWorks
Copy link

SidWorks commented Feb 6, 2022

Unfortunately, this form also comes with a potential security hazard because of the binding of this.

@Pomax
Copy link

Pomax commented Mar 31, 2024

@valsaven it's important to observe that if every case returns, we're no longer switching, we're just potentially wasting time getting to the return we need. Note that a switching code path (whether it uses break or return) has a runtime complexity of O(n), whereas looking up the "case" as a property on an object has a runtime complexity of O(1). Only when the switch is degenerate (i.e. it only has one case) will it perform as well as a lookup.

As for this bindings noted by @SidWorks, that's not related to whether you're using switch or a lookup object, that's purely related to how you've chosen to scope your code. For instance, we can trivially make "this" a non-issue by using modern JS and writing our code as an ES module, which doesn't have a globally scoped "this":

let actions = {};

/**
 * Any half-decent game code comes with a setup/init/bootstrap/etc function.
 */
function setup(actor, gamestate) {
  // Assign actions so that actor and gamestate are in-scope,
  // but because we're in a module, any `this` inside the arrow
  // functions will be undefined, because that's how JS works.
  Object.assign(actions, {
    hack: () => { ... },
    slash: () => { ... },
    run: () => { ... }
  }
};

/**
 * And similarly, any half-decent game code has a game loop.
 */
function gameLoop() {
  const errors = []
  // get the action from the user
  getQueuedUserActions().forEach(action => {
    // execute that action with an O(1) lookup, or note an error.
    actions[action]?.() ?? errors.push(new Error(`no action for ${action}!`));
  });
  // and then we do whatever error handling is necessary
  if (errors.length) {
    // While bearing in mind that properly written code means
    // `getQueuedUserActions` will never contain actions that
    // don't have a lookup, so this would qualify as a hard-crash.
    // Something _truly_ unaccounted for happened.
    throw new Error(`The game crashed because we wrote bad user input handling code`);
  }
}

export { setup, gameLoop };

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment