Implementing Rust-Like Pattern Matching in JS Pt. 3

Ross
Ross
Jun 28, 2020 · 5 min read
Photo by Markus Winkler on Unsplash

This is the third installment of an experiment in implementing some cool pattern matching functional features for your everyday JS’ing.

For the previous parts:

Way back at the end of part one I was discussing the shortcomings of the approach I had taken. We already addressed the first issue and made our sum types extensible. Now we need to tackle the issue of matching against native JS things. Let’s look at the definition of match.

const match = (instance, pattern) => {
if(!instance[TAG]) err('no TAG symbol in instance')
return instance.match(pattern)
}

As it is currently defined, match can never work on native JS objects. That’s because native JS objects don’t have a [TAG] entry, or a .match instance method. I’ve been advised many, many times and in loads of places to never EVER modify native JS prototypes because you’ll definitely break everything! So that’s exactly what I’m going to do — modify the prototypes!

First let’s give some JS things [TAG] entries:

Object.prototype[TAG]   = 'Object'
Array.prototype[TAG] = 'Array'
String.prototype[TAG] = 'String'
Function.prototype[TAG] = 'Function'
Number.prototype[TAG] = 'Number'

That will bypass the first part of our match function for pretty much every object ever defined by defaulting a [TAG] entry for the primary JS types.

But they still have no match methods, so our match function will never work with them. Let’s define a new Symbol to safely key our native JS objects with, since match already exists as a regex matching function on String:

//... 
MATCH = Symbol.for('match-sym'),
WILD = '_', //for replacing underscore everywhere...
//...

Now we can define a match method for everything. Literally everything (except Object.create(null) products).

Object.prototype[MATCH] = function(pattern) {
return (
this[TAG] in pattern ? pattern[this[TAG]](this)
: WILD in pattern ? pattern[WILD]()
: err('expected '+this[TAG]+' or '+WILD+' to be in pattern.')
)
}

That one definition defines match on all JS objects. Just overwriting it will ‘override’ the method when you want to define your own types though.

But wait, there’s more. We still need to change our Matchable constructor function to set match to the key [MATCH] to mirror the work we’ve done so far.

function Matchable(tag, pvt) {
this[TAG] = tag
this[SECRET] = pvt
//now match is set to the MATCH symbol.
this[MATCH] = function(pattern) { return (
tag in pattern ? pattern[tag](pvt)
: WILD in pattern ? pattern['_']()
: /*else*/ err('no match found for '+tag)
)
}
}

And in the match function:

const match = (instance, pattern) => {
if(!instance[TAG]) err('no TAG symbol in instance')
return instance[MATCH](pattern) //now uses the Symbol key
}

And that’s all there is to it. Now we can match whatever we encounter in the wild with our match function:

const test_pattern = {
A: ({value}) => `Got A(${value}).`,
B: () => `Got B.`,
Array: arr => 'Got an array.',
Object: o => 'Got an object.',
Function: f => 'Got a function.',
String: str => `Got string '${str}'.`,
Number: n => `Got number ${n}.`,
Boolean: b => `Got bool ${b}.`,
_:() => `Got something else...`,
}

We can match against any of those now in just under 100 lines. And even though we broke the rules and modified Object.prototype and friends, we actually did it in a pretty safe way, since no Symbol can ever equal another. That makes Symbol a pretty nice little unique identifier for our implementation. Now, unless we choose to expose the Symbols used in this library, we can pretty much be secure knowing the end user won’t tamper with the way that match works.

But hey, there’s yet another thing wrong with this! It can’t differentiate between overarching types and, therefore, if you define a sum type with a variant that is the same name as another variant from any other type, they may (read: will) clash. It’s not ‘type-safe’. So now it’s back to the drawing board to fix that.

Let’s start by defining another Symbol, this time to represent our sum type’s type-name (not the variants, but the overarching type).

//...
TYPE = Symbol.for('type-sym'),
//...

*Sidenote: astute readers may have noticed I’m using Symbol.for(/**/) now. That’s because the for method on Symbol actually adds the Symbol you define to the global symbol registry, which the Symbol() constructor does not actually do (it’s scoped locally without .for and so won’t work throughout your codebase of multiple files).

We’ll need to change Matchable's signature to accommodate this change:

//                  v~~~~~~~~~~~~~~~~ new parameter
function Matchable(type, tag, pvt) {
//...
this[TYPE] = type
//...
}

The next simplest answer to registering types would be to just create an object to associate expected tags with a type name:

const REGISTERED_TYPES = {}

…and we’ll write a register function to populate it:

const register_type = (type, tags) => {
if(REGISTERED_TYPES[type])
err(`type with name '${type}' already exists.`)
REGISTERED_TYPES[type] = {}
keys(tags).forEach(tag => REGISTERED_TYPES[type][tag] = tag)
}

Now let’s change our sum_type's signature and have it automatically update the contents of REGISTERED_TYPES each time it’s called.

//                 v~~~~~~~~~~~~~~~~~~~~~~ there's the new parameter
const sum_type = (name, constructors) => {
//...
const tags = {}
keys(constructors).forEach(k => tags[k] = k
register_type(name, tags)
//...
}

We make an object tags to hold the tag strings, and then assign each individual tag string as a key holding it’s own string value to the tags object. Then we can call register_type using tags as a parameter.

Let’s change our test sum type’s declaration to include the new name parameter:

//                           v~~~~~~~~~ the name parameter
const { A, B } = sum_type('MyType', {
A(value){ return { value } },
B(){}
})

Now if we were to log the REGISTERED_TYPES object we’d get something like this:

Object {
MyType: Object {
A: 'A',
B:'B',
}
}

Although REGISTERED_TYPES is populated, we aren’t using it anywhere yet. Let’s change match to actually use that data.

const match = (instance, pattern) => {
keys(pattern).forEach(k => {
const T = REGISTERED_TYPES[instance[TYPE]]
if(!(k in T) || !(k === WILD))
err('Pattern includes keys not in type '+ instance[TYPE])
})
if(!instance[TAG]) err('no TAG symbol in instance')
return instance[MATCH](pattern)
}

For each key in our pattern, check to see if that key is in the REGISTERED_TYPES object at key instance[TYPE]. If it finds that there is a key in the pattern that’s unexpected it will err.

Almost there! One last thing to fully implement our match function for everything. In the first part of this article I showed how to add tags to native objects. We can just as easily add a type:

Object.prototype[TYPE] = 'NativeType'register_type('NativeType', {
Object: 'Object',
Array: 'Array',
Boolean: 'Boolean',
Function: 'Function',
Number: 'Number',
String: 'String'
})

There we go. Now when we pass a pattern to the match function, it will check the instance type against the registered types and err if there are any unexpected tags in that pattern. We can finally try it out — play with this pen:

The Startup

Get smarter at building your thing. Join The Startup’s +725K followers.

Ross

Written by

Ross

Programming maniac, #JavaScript zealot. I'm crazy about #FunctionalProgramming and I love Rust. ETH coffee fund: 0x0c37584674e7143e03328254232102973a9cd468

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +725K followers.

Ross

Written by

Ross

Programming maniac, #JavaScript zealot. I'm crazy about #FunctionalProgramming and I love Rust. ETH coffee fund: 0x0c37584674e7143e03328254232102973a9cd468

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +725K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store