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