Matching
Pattern matching is the crown jewel of variant types. In general, pattern matching compares a given object to a series of patterns and executes the relevant code block when a match is found. Variant features a match expression and several more specific versions for use depending on the situation.
The term exhaustive gets tossed around a lot when talking about variant types, especially in the term exhaustiveness checking. Simply speaking, it's a check for whether or not you've handled all the possibilities. Having access to this feature greatly simplifies refactoring because breaking changes to the variant (adding a new case, for example) will point you to every point in the code that then needs to add support for that case to its handler.
match()
The match
expression inspects the type of an unknown variant and executes the code relevant to that type.
typescript
/*** Describe how some animal is lounging around the player's room.** returns: description [string]* */const describeAnimal = (animal: Animal) => match(animal, {cat: ({name}) => `${name} is sleeping on a sunlit window sill.`,dog: ({name, favoriteBall}) => [`${name} is on the rug`,favoriteBall ? `nuzzling a ${favoriteBall} ball.` : '.'].join(' '),snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,});
typescript
/*** Describe how some animal is lounging around the player's room.** returns: description [string]* */const describeAnimal = (animal: Animal) => match(animal, {cat: ({name}) => `${name} is sleeping on a sunlit window sill.`,dog: ({name, favoriteBall}) => [`${name} is on the rug`,favoriteBall ? `nuzzling a ${favoriteBall} ball.` : '.'].join(' '),snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,});
Not only was describeAnimal()
able to capture the serene beauty of domestic life with a pet, it did so while referencing information specific to the pet, like the color of a dog's favorite ball or the pattern of a snake's skin. It also did so without requiring any casting, null checks, or type checks on the part of the user.
The match()
function includes exhaustiveness checking. describeAnimal()
will gain the ease of mind of knowing its covered all the bases.
The Handler
The second object match
expects is the handler. It's an object with a property for each case of the unknown variant. The property key is the case's type
and the value is a function that takes a variant of that type and returns... well... anything.
Given
Animal
has possible typesdog
,cat
, andsnake
, this is the type of the handler.typescript
{dog: (x: Animal<'dog'>) => any,cat: (x: Animal<'cat'>) => any,snake: (x: Animal<'snake'>) => any,}typescript
{dog: (x: Animal<'dog'>) => any,cat: (x: Animal<'cat'>) => any,snake: (x: Animal<'snake'>) => any,}
The syntax seen in the code is enabled by destructuring the parameters of arrow functions.
The Return Type
The return type of a match()
expression will respect TypeScript's type inference. If every branch returns a number, the type of the expression will be number
. If there are a variety of types returned, match
's return type will be the union of those types.
typescript
// rate from 1-10const rating = (a: Animal) => match(a, {dog: ({name}) => name === 'Chewbacca' ? 10 : 7,cat: _ => 5,snake: ({name}) => name.startsWith('S') ? 7 : 4,})
typescript
// rate from 1-10const rating = (a: Animal) => match(a, {dog: ({name}) => name === 'Chewbacca' ? 10 : 7,cat: _ => 5,snake: ({name}) => name.startsWith('S') ? 7 : 4,})
type: const rating: (a: Animal) => number
_
Use _
as the label for throwaway arguments in the handler functions. This convention is concise and helps limit tsc
's whining when the noUnusedParameters
flag is on.
Now this would never happen, of course, but let's pretend I made a mistake while typing 4
, and accidentally wrote the string '4'
.
typescript
const rating = (a: Animal) => match(a, {dog: ({name}) => name === 'Chewbacca' ? 10 : 7,cat: _ => 5,snake: ({name}) => name.startsWith('S') ? 7 : '4',})
typescript
const rating = (a: Animal) => match(a, {dog: ({name}) => name === 'Chewbacca' ? 10 : 7,cat: _ => 5,snake: ({name}) => name.startsWith('S') ? 7 : '4',})
type: const rating: (a: Animal) => number | "4"
Thanks to this feature, this slip-up will raise a compiler error when the result of rating()
is used where a number is expected.
Inline Matching
As a type-safe and elegant flow control expression, match
can be used inline as part of other expressions. These can be language features or elements of other libraries.
Filtering a list of variants. Heterogeneous lists are a #1 use case for variants. Matching that list to make decisions allows for some very succinct and powerful code.
typescript
/*** Filter to the animals that are just quake-in-your-booties cool.* - cool cats haven't damaged any furniture* - cool dogs like red balls* - every snake is cool*/const coolAnimals = animals.filter(a => match(a, {cat: ({furnitureDamaged}) => furnitureDamaged === 0,dog: ({favoriteBall}) => favoriteBall === 'red',snake: _ => true,}));
typescript
/*** Filter to the animals that are just quake-in-your-booties cool.* - cool cats haven't damaged any furniture* - cool dogs like red balls* - every snake is cool*/const coolAnimals = animals.filter(a => match(a, {cat: ({furnitureDamaged}) => furnitureDamaged === 0,dog: ({favoriteBall}) => favoriteBall === 'red',snake: _ => true,}));
Last minute color selections. Conveniently switch based on type for various properties. Since literals are retained, this even works for fields like the react library @blueprintjs/core's icon
property which is required to be a valid literal from a selection.
typescript
<Iconicon='upload'color={match(uploadState, {sucess: _ => 'green',failure: _ => 'red',})}/>
typescript
<Iconicon='upload'color={match(uploadState, {sucess: _ => 'green',failure: _ => 'red',})}/>
Partial Matching
Sometimes it won't be necessary to handle every case, or many cases can be handled in the same way. In this scenario, add a default handler. There are two simple ways to do this.
Add a
default
case to the handler objecttypescriptconst rating = (animal: Animal) => match(animal, {cat: _ => 1,default: _ => 2,});typescriptconst rating = (animal: Animal) => match(animal, {cat: _ => 1,default: _ => 2,});The input to the
default
case is the full union (Animal
).Add a second parameter to handle the 'else' clause. This is sometimes called the
match-else
overload.typescriptconst rating = (animal: Animal) => match(animal, {cat: _ => 1,}, _ => 2);typescriptconst rating = (animal: Animal) => match(animal, {cat: _ => 1,}, _ => 2);The input to this function is more specific. Since we handled
cat
the type of_
for the else clause isn'tAnimal
. It's the more specific subsetAnimal<'dog'> | Animal<'snake'>
. As always, users are free to reference any properties common to that union.typescript// describe a file attributeconst attributeToString = (attr: Attribute) => match(attr, {Filename: ({payload}) => `Filename: ${payload}`,Resolution: ({width, height}) => `Resolution: ${width} x ${height}`,}, rest => `Unknown Attribute: ${rest.type}`);typescript// describe a file attributeconst attributeToString = (attr: Attribute) => match(attr, {Filename: ({payload}) => `Filename: ${payload}`,Resolution: ({width, height}) => `Resolution: ${width} x ${height}`,}, rest => `Unknown Attribute: ${rest.type}`);
Match Helpers
I have added two functions to assist with common use cases.
Returning a constant (
just()
).As a user engages with match more, their ring finger may become tired from typing excessive numbers of
() =>
and_ =>
expressions. I've made the functionjust()
available as shorthand for this use case.just(x)
returns a function() => x
.Note that
just()
can also be used in variant creation as a way of creating a case that takes no parameters and yet returns an object with multiple fields.Extracting the payload (
unpack()
).payload<T>()
is an extremely commonly used function. Instead of repeatedly typing({payload}) => payload
in order to extract the data, useunpack
.
ts
// Variant variety (payload, fields, empty).const Test1 = variantModule({Alpha: payload<string>(),Beta: fields<{prop: string}>(),Gamma: {},});type Test1<T extends TypeNames<typeof Test1> = undefined> = VariantOf<typeof Test1, T>;// Example of unpack, the regular way of doing things, and just.const test1Result = (thing: Test1) => match(thing, {Alpha: unpack,Beta: ({prop}) => prop,Gamma: just('gamma'),});
ts
// Variant variety (payload, fields, empty).const Test1 = variantModule({Alpha: payload<string>(),Beta: fields<{prop: string}>(),Gamma: {},});type Test1<T extends TypeNames<typeof Test1> = undefined> = VariantOf<typeof Test1, T>;// Example of unpack, the regular way of doing things, and just.const test1Result = (thing: Test1) => match(thing, {Alpha: unpack,Beta: ({prop}) => prop,Gamma: just('gamma'),});
🔮 matcher()
🔮 denotes preview content. These are features that are available, but not well-documented and may be modified in the near future as they see better integration.
The match
function is wonderful, but requires explicit handling of individual cases and so can be tiresome to use when multiple cases follow the same flow. This library provides an alternate matching tool called matcher()
that operates in a builder pattern. Here's our original describeAnimal function, but with a matcher()
instead.
typescript
const describeAnimal = (animal: Animal) => matcher(animal).when({cat: ({name}) => `${name} is sleeping on a sunlit window sill.`,dog: ({name, favoriteBall}) => [`${name} is on the rug`,favoriteBall ? `nuzzling a ${favoriteBall} ball.` : '.'].join(' '),snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,}).complete();
typescript
const describeAnimal = (animal: Animal) => matcher(animal).when({cat: ({name}) => `${name} is sleeping on a sunlit window sill.`,dog: ({name, favoriteBall}) => [`${name} is on the rug`,favoriteBall ? `nuzzling a ${favoriteBall} ball.` : '.'].join(' '),snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,}).complete();
It seems pretty familiar. This function shines when we can combine branches, so let's do that here. Unlike a snake, cats and dogs both have fur and enjoy having it pet.
typescript
const describeAnimal = (animal: Animal) => matcher(animal).when({snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,}).when(['cat', 'dog'], ({name}) => `${name} is hoping you will pet their fur.`).complete();
typescript
const describeAnimal = (animal: Animal) => matcher(animal).when({snake: s => `${s.name} is enjoying the heat of the lamp on his ${s.pattern} skin`,}).when(['cat', 'dog'], ({name}) => `${name} is hoping you will pet their fur.`).complete();
The matcher type is smart enough to understand which cases have already been handled. Attempting to handle a case twice will result in a compiler error. The .complete()
function is only available when all cases are handled. Partial matching is still entirely possible by using .execute()
instead.
Let's take a look at matcher()
in detail. This function accepts a single object as its only parameter. It returns a Matcher<T, H, K?>
where T is the instance of the variant, H is the current state of the handler object being built, and K is an optional type key (if your variants do not use type
and instead use __typeName
or kind
).
The return type of matcher()
follows the same rules as match()
(it will be a union of the handler return types). To actually execute the matcher, use one of its terminal methods, like complete()
.
To use this function
Call
matcher()
on a variable to create the matcher object.tsconst greeting = matcher(animal)...tsconst greeting = matcher(animal)...Handle one or more cases with one or more
.when()
calls.The
.when()
method has two overloads.The first accepts an object, which will operate just like
match()
ts....when({snake: just('Hello, snake.'),})ts....when({snake: just('Hello, snake.'),})The second accepts an array of keys a function to handle the variant types corresponding to those keys.
ts....when(['cat', 'dog'], just('Hello, furry one.'))ts....when(['cat', 'dog'], just('Hello, furry one.'))Just like
isType
, it's okay to use the constructor in this array instead.ts....when([Animal.cat, Animal.dog], just('Hello, furry one.'))ts....when([Animal.cat, Animal.dog], just('Hello, furry one.'))
Execute the handler with a terminal method. So far you've seen
complete()
, but that won't always be applicable or even available. Thecomplete()
method only exists on the matcher if every case has been handled. There are three terminals,.complete()
,.execute()
, and.else()
..complete()
gives you exhaustiveness checking. This function will only be defined if the handler completely covers the variant. Adding a new case will raise a compiler error in the relevant matchers as thecomplete()
function will disappear until the new case is handled..execute()
immediately executes the handler whether or not every case can be processed, a form of partial matching. If the handler is complete,.execute()
is functionally equivalent to.complete()
. However, if there are some cases unhandled,.execute()
may returnundefined
(and the return type will be updated to reflect this)..else(e => ...)
immediately executes the handler and processes any unhandled cases with the function passed into the.else()
method. Another form of partial matching, this is most similar to the match-else overload ofmatch()
.