ponyfoo.com

Pattern Matching in ECMAScript

Fix
A relevant ad will be displayed here soon. These ads help pay for my hosting.
Please consider disabling your ad blocker on Pony Foo. These ads help pay for my hosting.
You can support Pony Foo directly through Patreon or via PayPal.

There’s a stage 0 proposal for pattern matching in JavaScript. In this article we’ll take a look at what the proposal entails and also show how you might find it useful.

The proposal document has a few code examples, as usual. Here is one of them.

let length = vector => match (vector) {
  { x, y, z }: Math.sqrt(x ** 2 + y ** 2 + z ** 2),
  { x, y }: Math.sqrt(x ** 2 + y ** 2),
  [...]: vector.length,
  else: {
    throw new Error(`Unknown vector type`)
  }
}

Trying to make sense of that bit of code might prove challenging, given all the unfamiliar code, paired with an arrow function, let assignment, and a boatload of exponentiation operators. Let’s reduce it.

The Basics of ECMAScript Pattern Matching

The following example is a match expression which receives a point parameter. When point has an x property and a y property, the expression evaluates to [point.x, point.y].

const point = { x: 5, y: 7 }
const result = match (point) {
  { x, y }: [point.x, point.y]
}
console.log(result) // <- [5, 7]

For convenience, we might turn this into a function.

function matchPoint(point) {
  return match (point) {
    { x, y }: [point.x, point.y]
  }
}

Or an arrow function. See how terse this is?

const matchPoint = point => match (point) {
  { x, y }: [point.x, point.y]
}

We could make it more terse! In the { x, y } pattern above, x and y are bound to the properties on point by the same name, meaning we could write code like the following. Note that x and y would only be bound in the “match leg” for the { x, y } pattern, meaning that the only place where we can reference those bindings is in the case where that pattern is matched.

const matchPoint = point => match (point) {
  { x, y }: [x, y]
}

We could take this a step further, and match on arrays as well. In this case we’re matching an array with two elements, and binding them as x and y. Just for fun, we’ll call this one flipPoint.

const flipPoint = point => match (point) {
  { x, y }: [x, y],
  [x, y]: { x, y }
}
flipPoint([3, 7]) // { x: 3, y: 7 }
flipPoint({ x: 3, y: 7 }) // [3, 7]

Note that if point doesn’t match any pattern, a runtime error will occur.

matchPoint({ x: 3, z: 7 })
// <- Error

Alternatively, we can set up an else pattern. This will match when nothing else does.

const matchPoint = point => match (point) {
  { x, y }: [x, y],
  else: [0, 0]
}

matchPoint({ x: 3, z: 7 })
// <- [0, 0]

Instead of a implicitly returning an expression for the match leg, you can use a block. This is akin to how it works for arrow functions.

const matchPoint = point => match (point) {
  { x, y }: {
    return [x, y]
  },
  else: {
    throw new Error(`That's not even a point!`)
  }
}

More Patterns!

There are literal patterns. This means we can match things like null, undefined, true, false, in addition to numbers like 0, and strings like 'two'. These don’t seem all that useful but they may come in handy depending on your use case for pattern matching.

Object patterns are inclusive: the { x, y } pattern will match on an object shaped like { x, y ,z }.

const matchPoint = point => match (point) {
  { x, y }: [x, y]
}
matchPoint({ x: 2, y: 5, z: -1 })
// <- [2, 5]

If we still want to get all other properties like we would do while destructuring — maybe we consider them options — we can use a similar dot dot dot operator in pattern matching.

const matchPoint = point => match (point) {
  { x, y, ...options }: { point: [x, y], options }
}
matchPoint({ x: 2, y: 5, radius: 50, width: 3 })
// <- { point: [2, 5], options: { radius: 50, width: 3 } }

Object patterns allow for nesting. We can match an object that literally has { x: 3, y: 4 }, for example.

const matchNullPoint = point => match (point) {
  { x: 0, y: 0 }: [x, y]
}
matchNullPoint({ x: 0, y: 0 })
// <- [0, 0]

The nested pattern could also contain other object matchers.

const isUSD = item => match (item) {
  { options: { currency: 'USD' } }: true,
  else: false
}
isUSD({ value: 19.99, options: { currency: 'USD' } })
// <- true
isUSD({ value: 19.99, options: { currency: 'ARS' } })
// <- false

Array pattern matching is a little different in that it is exclusive by default: the [] pattern only matches empty array-like objects with a length property, unlike {} which would match any object.

Arrays can be made inclusive by adding the ... pattern. Unlike in rest, spread, or object pattern matching, it’s not necessary to name the rest parameter. We could simply do [...], meaning match an array of any length. Or we could do [first, ...] to match an array of any length and place the first item on a binding. Doing [...rest] places every element in a binding, and so on.

Arrays also support nested pattern matching just like objects did. The following examples matches an array, with a single element (because array patterns are exclusive unless we make them inclusive by adding ... to them), that is an object, that has x and y properties (and maybe some other properties because object patterns are inclusive).

const matchPoint = point => match (point) {
  [{ x, y }]: [x, y]
}
matchPoint([{ x: 1, y: 2 }]) // <- [1, 2]

Identifiers and Symbol.matches

We can also match with a regular expression. Note that we can only pass the identifier as a valid match pattern — numbers — and not the regular expression or any expression literal directly. This makes the syntax less complicated while keeping match powerful.

const numbers = /^-?\d+,\s*-?\d+$/
const matchPoint = point => match (point) {
  { x, y }: [x, y],
  [x, y]: [x, y],
  numbers: point.split(/,\s*/).map(n => parseInt(n))
}

matchPoint({ x: 7, y: -3 }) // <- [7, -3]
matchPoint([7, -3]) // <- [7, -3]
matchPoint(`7, -3`) // <- [7, -3]

Regular expressions as a pattern matcher are made possible thanks to symbols. The proposal includes Symbol.matches, which can be used to determine whether the host object matches the received value.

const threeDigitNumber = {
  [Symbol.matches](value) {
    return value >= 100 && value < 1000
  }
}

Now we can match using the identifier for threeDigitNumber in our match patterns.

const matchPoint = point => match (point) {
  threeDigitNumber: point.toString.split(``).map(n => parseInt(n))
}
matchPoint(735) // <- [7, 3, 5]

The proposal is in active development, and a few useful Symbol.matches extensions and built-in implementations are being considered at this time.

If something like basic type pattern matching Symbol.matches are offered natively, we’d be able to do something akin to type checking in native JavaScript, at least at runtime. This opens up the specification for interesting static type checking possibilities using a similar syntax, though, so the potential is there! 😘

const matchPoint = point => match (point) {
  { x: Number, y: Number }: [x, y]
}
matchPoint({ x: 1, y: 2 }) // <- [1, 2]
matchPoint({ x: 1, z: 2 }) // <- Error
matchPoint({ x: 1, y: 'two' }) // <- Error

As always, remember this proposal is at stage 0 and thus highly likely to change or fail to materialize as an official JavaScript language feature. 😅

Liked the article? Subscribe below to get an email when new articles come out! Also, follow @ponyfoo on Twitter and @ponyfoo on Facebook.
One-click unsubscribe, anytime. Learn more.

Comments (17)

Nandan Kulkarni wrote

What’s the whole point of this? It looks like a glorified wrapper to transform data structures. Can you provide a specific example where would this be suitable?

Brook Monroe wrote

This is a way of generating C++ style functions with multiple signatures, to wit:

double length(double x, double y, double z)
{
    // distance computation
}
double length(double x, double y)
{
    // distance computation
}
double length(std::array<> r)
{
    // return length of r
}

It would take the place of having to count the length of ...args and do type testing in order to return the desired result.

Anon wrote

I really really really hope type checking becomes a native JS thing

dpraimeyuu wrote

Having experience with F# and PureScript I would say it will truly be a game changer in terms of JS world :-)

Samuel Sylvester wrote

Not so seeing it’s use at the moment… Especially since we have desstucturing.

I’d really like to see operator overloading added to JS!

Nicolás Bevacqua wrote

Pattern matching is pretty effective for operator overloading. Look at how clean this piece of code is:

function getProduct(a, b, c) {
  const { slug, options, done } = match ({ a, b, c }) {
    { a: String, b: Object, c: Function }: { slug: a, options: b, done: c },
    { a: String, b: Function }: { slug: a, options: {}, done: b },
    { a: Object, b: Function }: { slug: null, options: a, done: b },
    { a: Function }: { slug: null, options: {}, done: a }
  }
}

What’s the alternative? Something like this, which is much more verbose and confusing.

function getProduct(a, b, c) {
  const { slug, options, done } = getParams({ a, b, c })
}

function getParams({ a, b, c }) {
  if ((a === null || typeof a === 'string') && b !== null && typeof b === 'object' && typeof c === 'function') {
    return { slug: a, options: b, done: c }
  }
  if ((a === null || typeof a === 'string') && typeof b === 'function' && c === undefined) {
    return { slug: a, options: {}, done: b }
  }
  if (a !== null && typeof a === 'object' && typeof b === 'function' && c === undefined) {
    return { slug: null, options: a, done: b }
  }
  if (typeof a === 'function' && b === undefined && c === undefined) {
    return { slug: null, options: {}, done: a }
  }
  throw new Error('Failed to parse arguments')
}
Nicolás Bevacqua wrote

Also could be done with array pattern, which would be shorter, provided the syntax supports it (which I’m not sure it does from reading the proposal draft document).

function getProduct(...params) {
  const { slug = null, options = {}, done } = match (params) {
    [slug: String, options: Object, done: Function]: { slug, options, done },
    [slug: String, done: Function]: { slug, done },
    [options: Object, done: Function]: { options, done },
    [done: Function]: { done }
  }
}
David Chang wrote

Using types like this within pattern matching wasn’t mentioned in the article - is this part of the proposal?

Nicolás Bevacqua wrote

The last example in the article covers this. The proposal suggests natively implementing Symbol.matches for types like Number so that it’s a matcher for any numbers – and the same would go for other types like Object or Function.

Konstantin Pschera wrote

Or we could do [first, ...] to match an array of any length and place the first item on a binding.

Wouldn’t that just match an array of length one or more?

David Chang wrote

Yes - it would match an array of length one or more; “any length” here refers to any non-zero length.

David Chang wrote

If you can return a block for the match leg, does that mean you would need to wrap an object with parentheses to disambiguate it from a block? in the same way that an arrow function requires parentheses when early returning an object?

eg

const makeObject = () => ({ anObject: true })
Nicolás Bevacqua wrote

I presume so. The proposed syntax is, at this time, very much like the arrow function syntax.

Nathan wrote

Nice explanation, but throughout the article all of the greater-than and less-than signs are displaying as “>” and “<”.

Nathan wrote

Sorry, the ampersands in my comment also seem to have been translated into HTML entities, presumably part of the same problem.

Nicolás Bevacqua wrote

Yeah. On vacation for a couple weeks unfortunately! Will resolve when I get back. Thanks for reporting it!

Nicolás Bevacqua wrote

This is now fixed :)