Alternative to JavaScript’s switch statement with a functional twist

With 8 simple lines of code, we get a powerful and weird-looking replacement for the switch statement with all the bells and whistles

Hajime Yamasaki Vukelic
codeburst

--

Photo by Yung Chang on Unsplash

2022 UPDATE: Remember that this article was written in 2017. Although many programmers got excited about this article, today I think this was a bad idea. Some of the so-called “shortcomings” of the switch statement are not really shortcomings but, quite frankly, just my stupidity and lack of experience. The switch statement is a perfectly serviceable language feature, there is absolutely nothing wrong with it. The same goes for most other JavaScript features such as the for and while loops, if, and var. It’s all about you and your skill as a programmer. And please do not fall for this and similar articles. You may still want to read it anyway, because it does show a neat trick with methods.

Why would anyone want to replace the switch statement in the first place? Some people believe the this statement is imperative, so just feels wrong in their functional code. Others believe if blocks are too verbose as an alternative. Yet others say it does not compose well. All of this is true to some degree depending on your coding style.

In my book, switch is a mixed bag of shortcomings some of which annoyed me royally:

  • it’s a block
  • it has a single block scope across all cases
  • it does not translate cases to values
  • it does not enforce the default case

What’s so bad about switch being a block?

A block is a groups of statements, not an expression. It cannot be returned, passed around, assigned to variables… you get the idea. You cannot say things like:

return switch (someVal) {
// ....
}

This means that each case statement will need to handle its own returning, assigning, etc.

What‘s this about ‘single block scope across all cases’?

Say you have a code like this:

switch (action) {
case 'UPDATE':
const val = msg[0]
return {...model, val}
case 'REPLACE':
const val = msg[1]
return {...model, val, active: false}
}

This code will not work because you can only declare val once for the entire block, which is the set of curlies associated with the switch.

This issue is easy to fix. You can declare a block for each case:

switch (action) {
case 'UPDATE': {
const val = msg[0]
return {...model, val}
}
case 'REPLACE': {
const val = msg[1]
return {...model, val, active: false}
}
}

This is not even so horrible, but it does result in some extra syntax.

Cases are not necessarily values

The semantics of the switch is not a map between the cases and values. It merely maps cases to groups of statements. This is perfectly fine, but does not mesh well with code that is predominantly declarative, where you think in terms of mappings between two sets of values.

If you are writing declarative code, you want to think in terms of:

case a: x -> x
case b: x -> y
case c: x -> z

To do this with switch, we want (one way or another) the switch to evaluate to some value based on the cases. We can do this by returning from each case, and it’s not the biggest deal in the world, but it does result in more syntax, and more mental overhead.

No enforced default

Without the default case, we miss out on an opportunity to make our code more robust by ensuring that all cases are covered, even the unforeseen one. In my typical usage, having a default, even if it’s just going to throw is a good way to ensure you aren’t missing something.

Coupled with declarative approach, a switch with the default case ensures that our function is total — it returns some kind of value for any and all inputs. (I won’t go into too much detail regarding why total functions are a good thing. I’ve talked about it in my previous article.)

Truth be told, missing default case can be flagged by an ESLint rule, so it’s not a big deal.

Match

The preceding paragraphs were a build-up to some code I wanted to share. Let’s dive right into it.

const matched = x => ({
on: () => matched(x),
otherwise: () => x,
})
const match = x => ({
on: (pred, fn) => (pred(x) ? matched(fn(x)) : match(x)),
otherwise: fn => fn(x),
})

Before going into how it works, let’s see it in action using an example:

match(50)
.on(x => x < 0, () => 0)
.on(x => x >= 0 && x <= 1, () => 1)
.otherwise(x => x * 10)
// => 500

The match function creates a context for the the value we pass into it. This context allows us to map the value in the context using any number of functions, each with a guard function that lets us ‘skip’ the evaluation if some condition is not met. We also have an otherwise() method that will catch the value if none of the guards allowed the matching function to be invoked.

As soon as one of the guards confirms the value, we make a context switch to matched putting the return value of the guarded function inside it. The new context will ignore any further matching, and return the wrapped value.

Let’s step through the example to see how this works in real life:

// EXAMPLE 1match(50)
// we are now in match context
.on(x => x < 0, () => 0)
// Since 50 is not < 0, we remain in match(50) context
.on(x => x >= 0 && x <= 1, () => 1)
// Since 50 is not between 0 and 1, we remain in match(50) context
.otherwise(x => x * 10)
// We are still in match(50), so otherwise callback is called,
// and we get 500 back

// EXAMPLE 2
match(0)
// we are now in match context
.on(x => x < 0, () => 0)
// 0 is not <0 so we remain in match(0) context
.on(x => x >= 0 && x <= 1, () => 1)
// Since 0 satisfies this guard, we use the return value of the
// function and put it in matched context. We are now in
// matched(1) context.
.otherwise(x => x * 10)
// We are in matched context, so the callback is ignored, and
// instead, we get 1 back

What does it solve?

The match function solves all of the issues using switch in a declarative code:

  • It acts as a case-sensitive mapping between the input and output values
  • It enforces the default case, providing total coverage
  • It is an expression, so it can be assigned, passed around, and so on
  • It does not introduce syntax bloat
  • Each case has its own scope (because it’s a function)

Because the guard functions are functions and not just simple values, this contraption doubles as if-else if-else, with the else clause enforced by otherwise.

All in all, we now have an abstraction that encapsulates the functionality of both switch and if-else if-else, which can be used as an expression with all the benefits of expressions. This is friendly to our declarative code and has an arguably cleaner syntax.

The bigger picture

In the grand scale of things, match demonstrates a very nice concept: a switchable context for our values (including functions as values). From this concept, I was able to construct other useful tools like this one:

const done = x => ({
attempt: () => done(x),
finally: fn => fn(x),
})
const until = (pred, x) => ({
attempt: fn => {
const y = fn(x)
return pred(y) ? done(y) : until(pred, x)
},
finally: (_, fn) => fn(x),
})

The until() function encapsulates the pattern of attempting to map a value to another until the result satisfies some condition. I will leave it as an exercise to my readers to figure out how until() works.

The grain of salt

I want you to take this article with a grain of salt. Treat it as one big nitpick by someone who unfoundedly thinks they have a refined taste in coding style. And that’s exactly what it boils down to — taste.

Yes, tools like match() and until() are nice, and they certainly do what the box says. But is this really worth it compared to exercising some discipline when using switch or if-else, and using linters to aid us?

I’ve made extensive use of match() and until() in my latest project, and I’m happy with them, but I will also admit that it took some time explaining how they work to my teammates, and I get an impression junior staff is not exactly comfortable with them yet. Time will tell, but I’m still evaluating the benefit of using this type of code.

I would love to hear your thoughts on the topic.

--

--

Helping build an inclusive and accessible web. Web developer and writer. Sometimes annoying, but mostly just looking to share knowledge.