2

Consider the following code snippet:

if (msg.operation == 'create') {
  model.blocks.push(msg.block)
  drawBlock(msg.block);
} else if (msg.operation == 'select' && msg.properties.snap == 'arbitrary') {
  doStuff(msg.properties.x, msg.properties.y);
} else if (msg.operation == 'unselect') {
  doOtherStuff(msg.properties.geometry);
}

Is there a way to refactor this so I can pattern match on msg, akin to the following invalid code:

msg match {
  case { operation: 'create', block: b } => 
    model.blocks.push(b); 
    drawBlock(b);
  case { operation: 'select', properties: { snap: 'arbitrary', x: sx, y: sy } } => 
    doStuff(sx, sy);
  case { operation: 'unselect', properties: { snap: 'specific' }, geometry: geom } => 
    doOtherStuff(geom);
}

Alternatively, what would be the most idiomatic way of achieving this in ES6, without the ugly if-then-else chain?


Update. Granted that this is a simplistic example where a full-blown pattern matching is probably unneeded. But one can imagine a scenario of matching arbitrary hierarchical pieces of a long AST.

TL;DR; the power of destructuring, accompanied with an automatic check if it is possible to do it or not.

icc97
  • 11,395
  • 8
  • 76
  • 90
Hugo Sereno Ferreira
  • 8,600
  • 7
  • 46
  • 92
  • 4
    Nope! Nothing like that is built into JavaScript. – Ry- Mar 28 '17 at 00:26
  • Probably wouldn't be that hard to write a function that accepts a value and a var-arg list of objects and have it check each. – Carcigenicate Mar 28 '17 at 00:28
  • 2
    why can't you `switch(msg.operation) { case 'create': ` etc - I don't understand the point of `block:b` and `id:id` in your pseudo code – Jaromanda X Mar 28 '17 at 00:32
  • Come on... This was just an example. Just imagine you're pattern-matching arbitrary pieces of a long AST. It's basically the power of destructuring, with a check if it is possible or not. – Hugo Sereno Ferreira Mar 28 '17 at 00:33
  • 1
    "most idiomatic" is a new phrase for getting around the restriction for opinion-based questions. – Heretic Monkey Mar 28 '17 at 00:35
  • 1
    `But one can simply imagine` - not from your original question - even now, one can not imagine what you want - perhaps you should be less vague? – Jaromanda X Mar 28 '17 at 00:41
  • There is a tc39 pattern-matching switch proposal: https://github.com/tc39/proposal-pattern-matching – icc97 Jun 13 '22 at 17:09

5 Answers5

4

You could write a match function like this, which (when combined with arrow functions and object destructuring) is fairly similar to the syntax your example:

/**
 * Called as:
 *   match(object,
 *     pattern1, callback1,
 *     pattern2, callback2,
 *     ...
 *   );
**/
function match(object, ...args) {
  for(let i = 0; i + 1 < args.length; i += 2) {
    const pattern = args[i];
    const callback = args[i+1];
    
    // this line only works when pattern and object both are JS objects
    // you may want to replace it with a more comprehensive check for
    //  all types (objects, arrays, strings, null/undefined etc.)
    const isEqual = Object.keys(pattern)
                          .every((key) => object[key] === pattern[key]);
    
    if(isEqual)
      return callback(object);
  }
}

// -------- //

const msg = { operation: 'create', block: 17 };

match(msg,
  { operation: 'create' }, ({ block: b }) => {
    console.log('create', b);
  },
  
  { operation: 'select-block' }, ({ id: id }) => {
    console.log('select-block', id);
  },
  
  { operation: 'unselect-block' }, ({ id: id }) => {
    console.log('unselect-block', id);
  }
);
Frxstrem
  • 38,761
  • 9
  • 79
  • 119
  • Nice `js-fu`; if only you could solve the "compare `object` to `pattern`" part ;) Can't we somehow attempt to "destructure" according to the `pattern` and test if it succeeded? – Hugo Sereno Ferreira Mar 28 '17 at 00:41
  • @HugoSerenoFerreira: You can make the pattern `({ id = _() })` where `_` is a function that throws a specific error, which you can then catch in the matching function, trying the next pattern. – Ry- Mar 28 '17 at 00:44
  • @HugoSerenoFerreira The sample code I put there does indeed work when `object` and `pattern` are proper objects (you can test it and see), but if you would use this function you'd maybe also want to take into account the cases where they could be strings, functions, arrays, null/undefined etc. – Frxstrem Mar 28 '17 at 00:48
2

You can use a higher order function and destructuring assignment to get something remotely similar to pattern matching:

const _switch = f => x => f(x);

const operationSwitch = _switch(({operation, properties: {snap, x, y, geometry}}) => {
  switch (operation) {
    case "create": {
      let x = true;
      return operation;
    }

    case "select": {
      let x = true;

      if (snap === "arbitrary") {
        return operation + " " + snap;
      }

      break;
    }

    case "unselect": {
      let x = true;
      return operation;
    }
  }
});

const msg = {operation: "select", properties: {snap: "arbitrary", x: 1, y: 2, geometry: "foo"}};

console.log(
  operationSwitch(msg) // select arbitrary
);

By putting the switch statement in a function we transformed it to a lazy evaluated and reusable switch expression.

_switch comes from functional programming and is usually called apply or A. Please note that I wrapped each case into brackets, so that each code branch has its own scope along with its own optional let/const bindings.

If you want to pass _switch more than one argument, just use const _switchn = f => (...args) => f(args) and adapt the destructuring to [{operation, properties: {snap, x, y, geometry}}].

However, without pattern matching as part of the language you lose many of the nice features:

  • if you change the type of msg, there are no automatic checks and _switch may silently stop working
  • there are no automatic checks if you covering all cases
  • there are no checks on tag name typos

The decisive question is whether it is worth the effort to introduce a technique that is somehow alien to Javascript.

2

I think @gunn's answer is onto something good here, but the primary issue I have with his code is that it relies upon a side-effecting function in order to produce a result – his match function does not have a useful return value.

For the sake of keeping things pure, I will implement match in a way that returns a value. In addition, I will also force you to include an else branch, just the way the ternary operator (?:) does - matching without an else is reckless and should be avoided.

Caveat: this does not work for matching on nested data structures but support could be added

// match.js
// only export the match function
const matchKeys = x => y =>
  Object.keys(x).every(k => x[k] === y[k])

const matchResult = x => ({
  case: () => matchResult(x),
  else: () => x
})

const match = x => ({
  case: (pattern, f) => 
    matchKeys (pattern) (x) ? matchResult(f(x)) : match(x),
  else: f => f(x)
})

// demonstration
const myfunc = msg => match(msg)
  .case({operation: 'create'},       ({block}) => ['create', block])
  .case({operation: 'select-block'},    ({id}) => ['select-block', id])
  .case({operation: 'unselect-block'},  ({id}) => ['unselect-block', id])
  .else(                                 (msg) => ['unmatched-operation', msg])

const messages = [
  {operation: 'create', block: 1, id: 2},
  {operation: 'select-block', block: 1, id: 2},
  {operation: 'unselect-block', block: 1, id: 2},
  {operation: 'other', block: 1, id: 2}
]

for (let m of messages)
  // myfunc returns an actual value now
  console.log(myfunc(m))
  
// [ 'create', 1 ]
// [ 'select-block', 2 ]
// [ 'unselect-block', 2 ]
// [ 'unmatched-operation', { operation: 'other', block: 1, id: 2 } ]

not quite pattern matching

Now actual pattern matching would allow us to destructure and match in the same expression – due to limitations of JavaScript, we have to match in one expression and destructure in another. Of course this only works on natives that can be destructured like {} and [] – if a custom data type was used, we'd have to dramatically rework this function and a lot of conveniences would be lost.

icc97
  • 11,395
  • 8
  • 76
  • 90
Mulan
  • 129,518
  • 31
  • 228
  • 259
1

Sure, why not?

function match(object) {
  this.case = (conditions, fn)=> {
    const doesMatch = Object.keys(conditions)
                        .every(k=> conditions[k]==object[k])

    if (doesMatch) fn(object)
    return this
  }
  return this
}


// Example of use:

const msg = {operation: 'create', block: 5}

match(msg)
  .case({ operation: 'create'},      ({block})=> console.log('create', block))
  .case({ operation: 'select-block'},   ({id})=> console.log('select-block', id))
  .case({ operation: 'unselect-block'}, ({id})=> console.log('unselect-block', id))
gunn
  • 8,999
  • 2
  • 24
  • 24
  • My only criticism of this is that it depends on a side-effecting function instead of returning a result itself. One of the greatest powers of pattern matching is that it is an *expression*, meaning it evaluates to a value that can be used elsewhere – Mulan Mar 28 '17 at 21:09
0

Given there's no easy way to properly do this until TC39 implemented switch pattern matching comes along, the best bet is libraries for now.

loadash

Go ol' loadash has a nice _.cond function:

var func = _.cond([
  [_.matches({ 'a': 1 }),           _.constant('matches A')],
  [_.conforms({ 'b': _.isNumber }), _.constant('matches B')],
  [_.stubTrue,                      _.constant('no match')]
]);
 
func({ 'a': 1, 'b': 2 });
// => 'matches A'
 
func({ 'a': 0, 'b': 1 });
// => 'matches B'
 
func({ 'a': '1', 'b': '2' });
// => 'no match'

patcom

One of the recommended libraries to look at, which has feature parity with the TC39 proposal for switch pattern matching, patcom, is quite small and nicely written - this is the main index.js:

import { oneOf } from './matchers/index.js'
export * from './matchers/index.js'
export * from './mappers.js'

export const match =
  (value) =>
  (...matchers) => {
    const result = oneOf(...matchers)(value)
    return result.value
  }

Here's the simple example:

import {match, when, otherwise, defined} from 'patcom'

function greet(person) {
  return match (person) (
    when (
      { role: 'student' },
      () => 'Hello fellow student.'
    ),

    when (
      { role: 'teacher', surname: defined },
      ({ surname }) => `Good morning ${surname} sensei.`
    ),

    otherwise (
      () => 'STRANGER DANGER'
    )
  )
}

So for yours something like this should work:

match (msg) (
  when ({ operation: 'create' }), ({ block: b }) => {
    model.blocks.push(b); 
    drawBlock(b);
  }), 
  when ({ operation: 'select', properties: { snap: 'arbitrary' } }), ({ properties: { x: sx, y: sy }}) => 
    doStuff(sx, sy)
  )
  when ({ operation: 'unselect', properties: { snap: 'specific' } }, ({ geometry: geom }) => 
    doOtherStuff(geom)
  )
)

match-iz

For people wanting to implement the whole thing themselves there is a recommended small library match-iz that implements functional pattern matching in currently 194 lines.

Supercharged switch

I'm wondering if something like this 'supercharged switch' might get close to what your after:

const match = (msg) => {
  const { operation, properties: { snap } } = msg;
  switch (true) {
    case operation === 'create':
      model.blocks.push(b); 
      drawBlock(b);
      break;
    case operation === 'select' && snap === 'arbitrary':
      const { properties: { x: sx, y: sy }} = msg;
      doStuff(sx, sy);
      break;
    case operation === 'unselect' && snap === 'specific':
      const { geometry: geom } = msg;
      doOtherStuff(geom)
      break;
  }
}

Reducers

Also the whole concept of matching on strings within objects and then running a function based on that sounds a lot like Redux reducers.

From an earlier answer of mine about reducers:

const operationReducer = function(state, action) {
    const { operation, ...rest } = action
    switch (operation) {
        case 'create':
            const { block: b } = rest
            return createFunc(state, b);
        case 'select':
        case 'unselect':
            return snapReducer(state, rest);
        default:
            return state;
    }
};

const snapReducer = function(state, action) {
    const { properties: { snap } } = action
    switch (snap) {
        case 'arbitrary':
            const { properties: { x: sx, y: sy } } = rest
            return doStuff(state, sx, sy);
        case 'specific':
            const { geometry: geom } = rest
            return doOtherStuff(state, geom);
        default:
            return state;
    }
};
icc97
  • 11,395
  • 8
  • 76
  • 90