Pattern Matching for Typescript and Javascript
matcha provides powerful pattern matching - inspired by f# and functional programming.
npm i matcha_match
...
import { patternMatch, with_ } from 'matcha_match'
import { $string } from 'matcha/runtime-interfaces/$string
Pattern matching takes a value and matches it against a series of patterns. The first pattern to match, fires the value (with type inferred from the pattern) into an accompanying function.
So... let's say we have name
.
We could do something like...
patternMatch(
name,
with_('garfield', matchedName => `${matchedName} is a cat`)
with_('odie', matchedName => `${matchedName} is a dog`)
)
In the above matchedName
in both cases is inferred to be a string - even though name
may be of unknown type.
That's because matchedName
infers it's type from the pattern.
Pattern Matching can be used to return a value. The result is the result of the function that fires upon match. If there is no match, then the original value is returned instead.
const name: string = getName()
const a = patternMatch(
name,
with_('garfield', matchedName => `${matchedName} is a cat`)
with_('odie', matchedName => `${matchedName} is a dog`)
)
In the above, since the value and both with_
arms all return a string - the compiler is smart enough to know that the resulting type is always string. Therefore a
gets an inferred type of string.
If one of the arms returned a number
then a
would have an inferred type of string | number
.
We've already seen how simple equality matches can be made...
const a = 'cat' as unknown
const b = patternMatch(
a,
with_('cat', _ => `hello kitty`),
with_('dog', _ => `hello doggy`)
)
But Pattern Matching is far more powerful than that...
Objects and arrays can be matched against a partial object / array.
const a = {
name: {
first: 'johnny',
last: 'bravo'
}
}
patternMatch(
a,
with_({ name: { first: 'johnny '} }, _ => `matching on first name`)
)
Which is particularly useful when used in combination with destructuring
patternMatch(
a,
with_({ name: { first: 'johnny '} }, ({ name: { first: b }}) => `Hey it's ${b}`)
)
Special runtime interfaces can be used to match against in place of values...
Here we use $string
in place of the literal 'johnny'.
const $matchPattern = {
name: {
first: $string
}
}
patternMatch(
a,
with_($matchedPattern, ({ name: { first: b }}) => `${b} is a string`)
)
It's also good to point out that a runtime interface automatically binds the correct type to the interface, so $string
is of type string
. So when a
is matched, it infers the type { name: { first: string }}
Runtime interfaces are powerful...
const a = [1, 2, 3]
patternMatch(
a,
with_($array($number), a => `${a} is an array of numbers`)
)
patternMatch(
a,
with_([1, $number, 3], ([_, b, __]) => `${b} is a number`)
)
const a = {
a: [1, 2],
b: [3, 3, 4],
c: [1, 5, 99]
}
patternMatch(
a,
with_($record($array($number)), a => `A record of arrays of numbers - whoa`)
)
const a = 'cat' as unknown
console.log(
patternMatch(
a,
with_($lt(100), _ => `< 100`),
with_($gt(100), _ => `> 100`),
with_(100, _ => `its 100`),
with_($unknown, _ => `no idea ... probably a cat`) // Use $unknown as a catch all
)
)
const a = 'cat' as string | number
patternMatch(
a,
with_($union([$string, $number]), _ => `a is string | number`)
)
Runtime interfaces include
$string
$number
$boolean
$array([])
$record()
$union([])
$unknown
$nothing
<- Use this to match on undefined & null$lt
$gt
$lte
$gte
const $even =
{
runtimeInterface: true,
test: (a: number) => a % 2 === 0
} as unknown as number
const $odd =
{
runtimeInterface: true,
test: (a: number) => a % 2 !== 0
} as unknown as number
console.log(
patternMatch(
101,
with_($even, _ => `number is even`),
with_($odd, _ => `number is odd`)
)
) // number is odd
A Runtime interface is an object with the property runtimeInterface: true
.
This tells the with_
function to treat the value as a Runtime Interface.
Primitive Runtime Interfaces have a type
property, but more complex ones have a test
function that determines whether a match is being made.
In both $odd
and $even
the subject is piped into the test function and a boolean is returned which determines whether or not the subject matches.
Note that the Runtime Interface object is coerced into the expected type should the path match.
const $validJson = {
userId: $number,
id: $number,
title: $string,
completed: $boolean
}
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json =>
patternMatch(
json,
match($validJson, json => console.log(`yay - ${ json.title }`)),
match($unknown, a => console.log(`Unexpected JSON response from API`))
)
)
Pattern matching becomes more powerful when used to drive type-cirtainty.
The return value of pattern matching is often a union
type or just plain unknown
.
Instead we can drive type-cirtainty by not returning a response to a variable at all. Instead we call a function passing in the value of cirtain-type from the inferred match.
In the below personProgram
only fires if bob
matches $person
so if personProgram
runs at all, then it is with type-cirtainty.
const $person = {
name: {
first: $string
}
}
type Person = typeof $person
const personProgram = (person: Person) => {
//this program runs with type cirtainty :D
console.log(`${person.name.first} is safe`)
}
const bob = getPerson(123)
patternMatch(
bob,
with_($person, personProgram /* this only runs if a match occurs */),
with_($nothing, _ => console.log('no match'))
)