Hugo Sereno Ferreira
Hugo Sereno Ferreira

Reputation: 8631

ES6 pattern match in Switch

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.

Upvotes: 2

Views: 3194

Answers (5)

icc97
icc97

Reputation: 12813

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;
    }
};

Upvotes: 1

Mulan
Mulan

Reputation: 135357

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.

Upvotes: 3

user6445533
user6445533

Reputation:

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.

Upvotes: 2

gunn
gunn

Reputation: 9165

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))

Upvotes: 1

Freyja
Freyja

Reputation: 40874

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);
  }
);

Upvotes: 5

Related Questions