TopicsBabel.jsThe Pattern Matching ProposalPattern Matching Proposal and Plugin

The Pattern Matching Proposal and a 0-Syntax Plugin

The TC39 Proposal Pattern Matching

The TC39 proposal Pattern Matching was approved for Stage 1 in the May 2018 TC39 meeting, and slides for that presentation are available here. Its current form was presented to TC39 in the April 2021 meeting (slides). Our clone of the comments about the slides are available at ULL-ESIT-PL/babel-learning in the file src/awesome/tc39-pattern-matching/slides.md.

It introduces three new concepts to Javascript:

  1. The “matcher pattern” DSL
  2. The match(){} expression, a general replacement for the switch statement
  3. The is boolean operator, which allows for one-off testing of a value against a matcher pattern

Let us see each in more detail.

The “matcher pattern” DSL

The “matcher pattern” is a new DSL closely based in part on the existing Destructuring Binding Patterns which allows

  1. Recursively testing the structure and contents of a value in multiple ways at once, and
  2. Extracting some of that structure into local bindings at the same time.

Let me mention that there is also in ECMA a Grammatical Category called AssignmentPattern. Here is an example of destructuring assignment pattern:

> const numbers = [];
> const obj = { a: 1, b: 2 };
> ({ a: numbers[0], b: numbers[1] } = obj);
{ a: 1, b: 2 }
> numbers
[ 1, 2 ]

The parentheses ( ... ) around the assignment are required when using object literal destructuring without a declaration.

Matcher patterns varieties

Matcher patterns can be divided into three general varieties:

  1. Value patterns, which test that the subject matches some criteria, like “is the string "foo"” or “matches the variable bar”.
when "foo": console.log("foo");
when bar: console.log("bar");
  1. Structure patterns, which test the subject matches some structural criteria like “has the property foo” or “is at least length 3”, and also let you recursively apply additional matchers to parts of that structure.
when { foo: let x, bar: let y }: console.log(`foo is ${x} and bar is ${y}`);

or:

const data = [1, 2, 3, 4];
 
match (data) {
  when { length: 0 }:
    console.log("The array is empty.");
  when { length: 1 }:
    console.log("The array has one element.");
  when { length: let len } and if (len >= 3):
    console.log(`The array has at least 3 elements. Length: ${len}`);
  default:
    console.log("The array has some other length.");
}
  1. Combinator patterns, which let you match several patterns in parallel on the same subject, with simple boolean and/or logic.
when >= 10 and < 20: console.log("mid");

The match(){} expression

The match(){} expression intends to be a general replacement for the switch statement that uses matcher patterns to resolve to one of several values,

match(<subject-expression>) {
    when <pattern>: <value-expression>;
    when <pattern>: <value-expression>;
    ...
    default: <value-expression>;
}

For instance:

match(val) {
    when < 10: console.log("small");
    when >= 10 and < 20: console.log("mid");
    default: "large";
}

The is boolean operator

The is boolean operator allows for one-off testing of a value against a matcher pattern, potentially also introducing bindings from that test into the local environment. The code below checks the types of the values in the array:

var json = {
  'user': ['Lily', 13]
};
if( json is {user: [String and let name, Number and let age]} ) { // let name = 'Lily', let age = 13
  print(`User ${name} is ${age} years old.`);
}

is equivalent to all these manual checkings:

if ( json.user !== undefined ) {
  var user = json.user;
  if (Array.isArray(user) &&
      user.length == 2 &&
      typeof user[0] == "string" &&
      typeof user[1] == "number") {
    var [name, age] = user;
    print(`User ${name} is ${age} years old.`);
  }
}

The Babel Plugin for the TC39 Pattern Matching Proposal

The plugin https://github.com/iptop/babel-plugin-proposal-pattern-matching by iptop implements

  1. A zero JS grammar modification of the proposal,
  2. It offers a good performance
  3. Once the Babel parser will be extended, it provides the support for a straightforward implementation of https://github.com/tc39/proposal-pattern-matching.

The expression Zero Grammar used here means that the plugin does not introduce new syntax to the JavaScript language. The match keyword is substituted by a function call to a function with name match. Also, the when <pattern>: <value-expression>; disappears and is replaced by function parameters of match:

const fib = n=>match(n)(     // match(n) {
        (v=1)=>1,            //   when == 1: 1:
        (v=2)=>1,            //   when == 2: 1;
        _=>fib(_-1)+fib(_-2) //   default: fib(n-1)+fib(n-2);
)                            // }
 
console.log(fib(10))

This strongly simplifies the implementation of the plugin avoiding to modify the lexer and parser of the Babel compiler. An analysis and transformation of the AST is enough to implement the plugin.

Installation and Setup of babel-plugin-proposal-pattern-matching

To use this Babel plugin, you need to install it and configure Babel to use the plugin:

  1. Install the Plugin:

    npm install babel-plugin-proposal-pattern-matching
  2. Configure Babel: Add the plugin to your Babel configuration (.babelrc or babel.config.js):

    {
      "plugins": ["babel-plugin-proposal-pattern-matching"]
    }

With this setup, you can use pattern matching in your JavaScript code, as demonstrated in the example.

How it works

Consider the example ULL-ESIT-PL/babel-learning/fib-easy.js:

➜ tc39-pattern-matching git:(main) ✗ cat fib-easy.js

➜  tc39-pattern-matching git:(main) ✗ cat -n fib-easy.js 
     1  import match from 'babel-plugin-proposal-pattern-matching/match.js'
     2  const fib = n=>match(n)(
     3          (v=1)=>1,
     4          (v=2)=>1,
     5          _=>fib(_-1)+fib(_-2)
     6  )
     7
     8  console.log(fib(10))
     9  // -> 55

This defines the Fibonacci function fib using an arrow function and pattern matching.

Matcher patterns are a new DSL, closely inspired by destructuring patterns, for recursively testing the structure and contents of a value while simultaneously extracting some parts of that value as local bindings for use by other code.

Matcher patterns can be divided into three general varieties:

  • Value patterns, which test that the subject matches some criteria, like “is the string "foo"” or “matches the variable bar”.
  • Structure patterns, which test the subject matches some structural criteria like “has the property foo” or “is at least length 3”, and also let you recursively apply additional matchers to parts of that structure.
  • Combinator patterns, which let you match several patterns in parallel on the same subject, with simple boolean and/or logic.

The plugin visits the CallExpression nodes corresponding to calls match(n)(f1, f2, ..., fn) replacing the AST node by a function that uses a sequence of if statements to match the patterns that will be built based on the actual value of n.

See the entry point of the plugin at src/index.js:

module.exports = (babel) => {
  return {
    visitor: {
      ImportDeclaration (path, state) {
        resolveImportDeclaration(babel, path, state)
      },
      CallExpression (path, state) {
        resolveCallExpression(babel, path, state)
      }
    }
  }
}

The manipulation of the AST is done in the src/visitor/call-expression.js module. When the identifier used in the callee of the CallExpression node is match, the plugin transforms the AST using the transformMatch function:

const transformMatch = require('../transform/match')
const transformFnMatch = require('../transform/fnmatch')
module.exports = (babel, path, state) => {
  const $callee = path.get('callee')
  const $$callee = $callee.node
  if ($$callee.type == 'Identifier') {
    /* Extract AST tokens from NodePath */
    const astTag = $callee.getData('pattern-matching')
    if (astTag == 'match') {
      transformMatch(babel, $callee)
    }
 
    if (astTag == 'fnmatch') {
      transformFnMatch(babel, $callee)
    }
  }
}

The transformMatch function builds the replacement AST node using this Babel template

(v=> {
    const UID = EXP
    BLOCKS
    throw new Error("No matching pattern");
  })()

that template can be clearly seen in the shape of the generated code fib-easy.mjs:

const fib = n => (v => {
  const _uid = n;                      // const UID = EXP
  if (_uid === 1) {                    // BLOCKS
    return 1;
  }
  if (_uid === 2) {
    return 1;
  }
  return fib(_uid - 1) + fib(_uid - 2); // END BLOCKS
  throw new Error("No matching pattern");
})();

this is the actual code of the transformMatch function:

module.exports = function transformMatch (babel, referencePath) {
  const $root = referencePath.parentPath.parentPath
  const $$uid = $root.scope.generateUidIdentifier('uid')
  const $matching = getMatching($root)
  const $$matching = $matching.node
  const $patterns = getPatterns($root)
  const $$blocks = transformPatterns(babel, $patterns, $$uid).filter(item => item)
 
  const $$IIFE = babel.template(`
    (v=> {
      const UID = EXP
      BLOCKS
      throw new Error("No matching pattern");
    })()
    `)({
    UID: $$uid,
    EXP: $$matching,
    BLOCKS: $$blocks
  })
  $root.replaceWith($$IIFE)
}

So what remains to understand is how the transformPatterns function works.

Four cases are considered: assign patterns, identifier patterns, array patterns and object patterns:

function transformPatterns (babel, $patterns, $$uid) {
  return $patterns.map($pattern => {
    const $param = $pattern.get('params.0')
    const $$param = $param.node
    switch ($$param.type) {
      case 'AssignmentPattern':
        return createIFBlock(babel, $pattern, $param, $$uid)
      case 'Identifier':
        return createReturnBlock(babel, $pattern, $param, $$uid)
      case 'ArrayPattern':
        return createDeconstruction(babel, $pattern, $param, $$uid, 'ArrayPattern')
      case 'ObjectPattern':
        return createDeconstruction(babel, $pattern, $param, $$uid, 'ObjectPattern')
    }
  })
}

Our example uses the Identifier case. The createReturnBlock function is used to build the AST node for the pattern matching:

function createReturnBlock (babel, $pattern, $param, $$uid) {
  const paramName = $param.get('name').node
  const $body = $pattern.get('body')
  const $$body = $body.node
  $body.scope.rename(paramName, $$uid.name)
  const $$block = babel.template(`
    return RET
    `)({
    RET: resolveBody(babel, $$body)
  })
  return $$block
}

This way the initial code is transformed to:

➜  tc39-pattern-matching git:(main) ✗ npx babel fib-easy.js -o fib-easy.mjs
➜  tc39-pattern-matching git:(main) ✗ cat -n fib-easy.mjs
     1  import match from 'babel-plugin-proposal-pattern-matching/match.js';
     2  const fib = n => (v => {
     3    const _uid = n;
     4    if (_uid === 1) {
     5      return 1;
     6    }
     7    if (_uid === 2) {
     8      return 1;
     9    }
    10    return fib(_uid - 1) + fib(_uid - 2);
    11    throw new Error("No matching pattern");
    12  })();
    13  console.log(fib(10));
    14  // -> 55

Notice that v is not used in the transformed code. The _uid variable is used to store the value of n and the patterns are matched against it.

That when executed gives:

➜  tc39-pattern-matching git:(main) ✗ node fib-easy.mjs 
55