Reputation: 8631
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
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.
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'
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)
)
)
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.
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;
}
}
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
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
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:
msg
, there are no automatic checks and _switch
may silently stop workingThe decisive question is whether it is worth the effort to introduce a technique that is somehow alien to Javascript.
Upvotes: 2
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
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