• Stars
    star
    178
  • Rank 214,989 (Top 5 %)
  • Language
    TypeScript
  • Created about 5 years ago
  • Updated about 1 year ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

Rust-style pattern matching for TypeScript

safety-match

safety-match provides pattern matching for JavaScript and TypeScript.

High-level Explanation

Pattern matching in JavaScript/TypeScript. When using TypeScript, it identifies non-exhaustive matches and knows the types of data included in variants.

In short, it brings the user experience of Rust's enum pattern matching to TypeScript.

Why?

The point of safety-match is that I wanted to bring Rust's experience of pattern-matching on enums to JavaScript.

Let me explain that experience a bit.

Enums in Rust are types that describe different "variants" that live in the same type. So, you might want an enum that holds "off", and "on". Or an enum that holds "loading", "loaded", and "error". Any time you have a value that can take on one of several distinct states, you can model it with an enum.

When you define an enum in Rust, you use syntax like this:

enum Message {
  Quit,
  ChangeColor(i32, i32, i32),
  Write(String),
}

The first line defines the name of the enum; in this case, Message.

Every line inside of the curly braces ({}) defines a variant of the enum.

Some of the variants can hold additional data inside them (in this case for example, Write holds a String), but some don't hold any extra data (like Quit in this case).

Once you have made an enum, you can use it like a type, and match over instances of it:

// Given msg is an instance of the Message enum
match msg {
  Message::Quit => quit(),
  Message::ChangeColor(r, g, b) => change_color(r, g, b),
  Message::Write(s) => println!("{}", s),
};

On the first line, we use the match keyword to do a pattern match. match takes an expression and then branches based on its value.

Then inside the curly braces ({}), each line tells the program what to do if the variant on the left side of the => matches the one in msg.

You could also use _ inside the curly braces to mean "and if it's any variant not listed here":

// Given msg is an instance of the Message enum
match msg {
  Message::Quit => quit(),
  // This _ would get used for ChangeColor or Write, or any other variants added to the enum in the future.
  _ => println!("Not quitting!"),
};

At first glance, it looks similar to a switch statement in JavaScript; you could imitate it using a switch statement by doing something like this:

type Message =
  | { variant: "Quit" }
  | { variant: "ChangeColor", r: number, g: number, b: number }
  | { variant: "Write", s: string };

const msg = /* get a Message from somewhere */;

switch(msg.variant) {
  case "Quit": {
    quit();
    break;
  }
  case "ChangeColor": {
    change_color(msg.r, msg.g, msg.b);
    break;
  }
  case "Write": {
    console.log(msg.s);
    break;
  }
}

But Rust's match is more powerful than JavaScript's switch for several reasons:

  • When using match, if you don't handle all the variants, the compiler will warn you that you forgot some
  • With switch, you have to remember to put a break in every case, otherwise the code execution will fall through. This behavior is not present with match.
  • JavaScript's switch is a statement, not an expression, so if you want to create a value based on a switch statement, you can't just do const something = switch(...) {...}. You have to instead declare an empty variable, and then fill it in in every case.

Once you have gotten used to programming using match, it's hard to go back. switch or if/else feels clunky, and all the nice warnings your compiler gave you to help you aren't there anymore.

So, I built safety-match to bring this experience to JavaScript, by leveraging TypeScript.

Here's what it looks like. Note that instead of using the word "enum" like in Rust, I opted to instead call them "Tagged Unions", because TypeScript already has a concept of "enums", and I didn't want to confuse people.

import {makeTaggedUnion, none} from "safety-match";

const Message = makeTaggedUnion({
  Quit: none,
  ChangeColor: (r: number, g: number, b: number) => ({ r, g, b }),
  Write: (output: string) => output,
};

const msg = Message.Quit;
// Or:
const msg = Message.ChangeColor(127, 255, 0);
// Or:
const msg = Message.Write("Hello");

// But whichever you do, once you have a message:
msg.match({
  Quit: () => quit(),
  ChangeColor: ({r, g, b}) => change_color(r, g, b),
  Write: (output) => console.log(output),
});

It's not quite as succinct, since we're limited to JavaScript's syntax, but hopefully you can see the similarity to Rust's enum and match.

My solution provides the same advantages over switch statements that I mentioned earlier:

  • You don't have to put breaks in.
  • msg.match(...) is an expression, and it evaluates to the return value of each match handler.
  • If you don't handle all the variants, TypeScript will warn you that you forgot some.

I'll explain everything that's going on in the "Usage and Explanation" section below.

Usage and Explanation

If you have not already read the "Why?" section, I highly recommend you do so. It explains some concepts and background knowledge that are necessary to understand why we're going through all this trouble.

To use safety-match, first you import two things from it: makeTaggedUnion and none:

import { makeTaggedUnion, none } from "safety-match";

makeTaggedUnion is a function that:

  • You call with an object whose property values are either functions or none. We call this object you pass in a DefinitionObject,
  • Returns a new object to you. We call this object that gets returned a TaggedUnion.

You can visualize it like this:

type DefinitionObject = { [key: string]: Function | typeof none };
type TaggedUnion = {/* We'll explain what's in this object below! */};

makeTaggedUnion = (defObj: DefinitionObject) => TaggedUnion;

Each property key on a DefinitionObject is called a "Variant".

The properties on the TaggedUnion depend on the properties that were present on the DefinitionObject you passed in.

The key of each property matches the key of the property on the DefinitionObject, so if you passed in a DefinitionObject with two properties on it, then you would get a TaggedUnion with two properties on it:

import { makeTaggedUnion, none } from "safety-match";

const myTaggedUnion = makeTaggedUnion({ on, off }); // Don't worry about the values here for now; we'll explain that below.
console.log(Object.keys(myTaggedUnion)); // ["on", "off"]

The value of each property on the returned TaggedUnion depends on the value of the property with the same name on the DefinitionObject.

In order to understand what the values are, you'll need to understand a type called MemberObject.

A MemberObject is an object that represents an "instance" of the union you're describing with makeTaggedUnion. They're what you can match against! Every MemberObject has a string representing which variant it is and can hold some data. You can visualize them like this:

type MemberObject = {
  variant: string;
  data: any;
};

So, for each property that was on your DefinitionObject, the corresponding property on the TaggedUnion is as follows:

  • If the DefinitionObject's property value was none (the other thing you imported), then the TaggedUnion's corresponding property is a MemberObject whose data property holds undefined, and whose variant property holds the key from the property on the DefinitionObject.
  • If the DefinitionObject's property value was instead a function, then the TaggedUnion's corresponding property is a function that accepts the same parameters as the function on the DefinitionObject, and returns a MemberObject whose data property holds the return value of the function on the DefinitionObject, and whose variant property holds the key from the property on the DefinitionObject.

You can visualize this as follows:

type TaggedUnion = {
  [for every property in the DefinitionObject you passed in]:
    | { /* if the property value was none: */
      variant: the property key,
      data: undefined
    }
    | / * if the property value was a function: */
      (...args: Parameters<the function that was on this property>) => {
        variant: the property key,
        data: ReturnType<the function that was on this property>
      }
}

So, more concretely, if we did this:

import { makeTaggedUnion, none } from "safety-match";

const myTaggedUnion = makeTaggedUnion({
  on: (voltage: number, current: number) => ({ voltage, current }),
  off: none,
});

Then myTaggedUnion would have this type:

{
  on: (voltage: number, current: number) => {
    variant: "on",
    data: { voltage: number, current: number }
  },
  off: {
    variant: "off",
    data: undefined
  }
}

Which means that you could get a MemberObject from myTaggedUnion like this:

const memberObj = myTaggedUnion.off; // memberObj is a MemberObject with variant "off" and data undefined
const anotherMemberObj = myTaggedUnion.on(3.3, 0.1); // anotherMemberObj is a MemberObject with variant "on" and data { voltage: 3.3, current: 0.1 }

Now, the point of doing all this, is that you can match over a MemberObject whose variant you don't know, and treat it differently depending on which variant it is.

Remember earlier how I said you could visualize a MemberObject like this?

type MemberObject = {
  variant: string;
  data: any;
};

Well, that's not actually the whole story. MemberObjects also have a match property on them:

type MemberObject = {
  variant: string;
  data: any;
  match: Function;
};

It's a function that you can call to branch execution depending on the variant property of the MemberObject.

To use it, you pass in an object we call the "Cases Object". This object should have a property for each variant, whose value is a function to be run if the MemberObject being matched has the variant in question. The function will receive the MemberObject's data.

Here's what it looks like to use, using a MemberObject from the myTaggedUnion from earlier code blocks:

// Assuming a variable named `memberObj` is defined, which is a MemberObject from `myTaggedUnion`:
memberObj.match({
  on: ({ voltage, current }) => {
    console.log(`Voltage: ${voltage}, Current: ${current}`);
  },
  off: () => {
    console.log("The system is off.");
  },
});

We can also use the property key _ in the Cases Object. If we do that, we don't have to specify every variant; any variants we don't specify will get handled by the _ handler.

Using _ isn't very useful for a TaggedUnion with only 2 variants, but with more variants, it's more useful. Here's an example that uses a TaggedUnion with more variants:

const LoadState = makeTaggedUnion({
  Unstarted: none,
  Loading: (percentLoaded: number) => percentLoaded,
  Loaded: (response: Buffer) => response,
  Error: (error: Error) => error,
});

const state = /* a MemberObject from LoadState */

const amountLoaded: number = state.match({
  Loading: (percentLoaded) => percentLoaded,
  Loaded: () => 100,
  _: () => 0,
});

const errorMessage: string | null = state.match({
  Error: (error) => error.message,
  _: () => null,
});

Now that you understand:

  • How to make a TaggedUnion,
  • how to get MemberObjects from that TaggedUnion,
  • and how to use match on MemberObjects to branch behavior,

The only thing left that you need to know is how to get a TypeScript type that describes a MemberObject for a given TaggedUnion.

This is important, since the idea of safety-match is that you'll pass MemberObjects around that represent values in your application. So you'll need to annotate functions that receive or return MemberObjects appropriately.

The way you do this is by using a helper type from the safety-match package called MemberType:

import { MemberType } from "safety-match";

Then you pass your TaggedUnion in as a type parameter to MemberType to get a new type that described the MemberObjects for that TaggedUnion. Note that you have to use typeof:

const myTaggedUnion = makeTaggedUnion({
  on: (voltage: number, current: number) => ({ voltage, current }),
  off: none,
});

type myTaggedUnionMember = MemberType<typeof myTaggedUnion>;

Now you can use it anywhere you would use a type annotation:

// In a variable definition...
const memberObj: myTaggedUnionMember = myTaggedUnion.off;

// In a function parameter...
function displayStringForMemberObj(obj: myTaggedUnionMember) {
  return obj.match({
    on: (voltage: number, current: number) =>
      `voltage: ${voltage} volts, current: ${current} amps`,
    off: () => `system is off`,
  });
}

// Etc

You can even give the member type the same name as the TaggedUnion variable:

const LoadState = makeTaggedUnion({
  Unstarted: none,
  Loading: (percentLoaded: number) => percentLoaded,
  Loaded: (response: Buffer) => response,
  Error: (error: Error) => error,
});
type LoadState = MemberType<typeof LoadState>;

let state: LoadState = LoadState.Unstarted;

Note About the variant Property

Although a MemberObject has a variant property, and you could theoretically use it in an if or switch statement, you should generally rely on .match for branching behavior instead.

However, it's often useful to use the variant property when logging a MemberObject.

Note about generics

To use generics in your tagged union type, ie to represent a Maybe<T> with Some and None, you'll have to use a bit of boilerplate. See this issue comment for an example.

License

MIT

More Repositories

1

hex-engine

A modern 2D game engine for the browser.
TypeScript
661
star
2

fs-remote

πŸ“‘ Drop-in replacement for fs that lets you write to the filesystem from the browser
JavaScript
235
star
3

switch-joy-con

Use Nintendo Switch Joy-Cons as input devices (Bluetooth)
JavaScript
196
star
4

pheno

Simple, lightweight at-runtime type checking functions, with full TypeScript support
TypeScript
129
star
5

eslint-config-unobtrusive

πŸ’› ESLint config that only helps you, and otherwise stays out of your way
JavaScript
117
star
6

chai-jest-snapshot

Chai assertion that provides Jest's snapshot testing
JavaScript
101
star
7

suchibot

A cross-platform AutoHotKey-like thing with TypeScript as its scripting language
TypeScript
99
star
8

react-state-tree

Drop-in replacement for useState that persists your state into a redux-like state tree
TypeScript
98
star
9

yavascript

shell script replacement; write shell scripts in js instead of bash, then run them with a single statically-linked file
TypeScript
76
star
10

react-testing-example-lockscreen

JavaScript
72
star
11

react-send

πŸ“¨ Separate your component's markup from their position in the render tree
JavaScript
70
star
12

test-it

Test-It is a test framework that gives you the best of node AND the browser.
TypeScript
53
star
13

equivalent-exchange

Transmute one JavaScript string into another by way of mutating its AST. Powered by babel and recast.
TypeScript
52
star
14

transform-imports

Tools that make it easy to codemod imports/requires in your JS
JavaScript
50
star
15

describe-component

πŸ““ Consistent React unit testing with zero boilerplate!
JavaScript
46
star
16

grep-ast

CLI tool to grep files for AST patterns using css-like selector strings
JavaScript
41
star
17

gmod-server-docker

Garry's Mod server running in a docker container, with a volume exposed for customization
Shell
37
star
18

quinci

🀡 Self-hosted, minimal GitHub CI server
JavaScript
34
star
19

webview

A cross-platform program that opens either a URL or files from disk in a native webview.
Go
28
star
20

half-life-vox-console

Website where you can make Half Life's VOX say stuff
JavaScript
27
star
21

eslint-plugin-esquery

Simple, user-created ESLint rules, right in their eslint config
JavaScript
22
star
22

line-sticker-downloader

Tool that downloads stickers or emojis from the LINE store
JavaScript
22
star
23

require-browser

Easy-to-use require function for your browser
JavaScript
21
star
24

use-legacy-state

πŸ¦… Custom React hook; drop-in replacement for this.setState
JavaScript
20
star
25

prs-merged-since

CLI and JS API to list all PRs merged into a GitHub repo since a given tag
JavaScript
19
star
26

run-on-server

πŸ‘©β€πŸ’» API generator that lets you write code as if you had serverside eval on the client
JavaScript
18
star
27

serializable-types

Runtime type assertion and serialization system
JavaScript
15
star
28

glomp

Lightweight, clearly-defined alternative to file glob strings
TypeScript
15
star
29

popularity-contest

Find the most-imported symbols in your JavaScript project
JavaScript
13
star
30

webview-node

Node wrapper around suchipi/webview.
JavaScript
13
star
31

react-boxxy

πŸ“¦ An extendable base component for React DOM.
JavaScript
12
star
32

arkit-face-blendshapes

A website which shows examples of the various blendshapes that can be animated using ARKit.
JavaScript
12
star
33

atom-morpher

Atom Package that helps you run code transformations on the current buffer
JavaScript
11
star
34

convert-to-dts

Convert some JavaScript/TypeScript code string into a .d.ts TypeScript Declaration code string
TypeScript
11
star
35

nice-path

treat filesystem paths as objects and distinguish between relative/absolute paths
TypeScript
10
star
36

simple-codemod-script

How to write your own JS/TS codemods, with comments and resources
TypeScript
10
star
37

parallel-park

Parallel/concurrent async work, optionally using multiple threads or processes
TypeScript
10
star
38

cleffa

CLI utility that parses argv, loads your specified file, and passes the parsed argv into your file's exported function. Supports ESM/TypeScript/etc out of the box.
JavaScript
10
star
39

mark-applier

Markdown-to-Website Generator, GitHub README style
TypeScript
10
star
40

has-shape

Very tiny function that checks if an object/array/value is shaped like another, with TypeScript type refining.
JavaScript
9
star
41

match-discriminated-union

TypeScript pattern match function for any discriminated union type
TypeScript
8
star
42

iterate-all

Converts an iterable, iterable of Promises, or async iterable into a Promise of an Array.
TypeScript
8
star
43

resolve-everything

walk your project's import/require tree and print all the relationships
TypeScript
8
star
44

Polycode-Binaries

Precompiled binaries of the Polycode Lua IDE
7
star
45

concubine

Create your own hooks system like React's
TypeScript
7
star
46

jsxdom

JSX factory that creates HTML elements directly
TypeScript
7
star
47

little-api

A simple JSON-over-HTTP/WebSocket RPC server and client
JavaScript
7
star
48

markdown-slice

tool that prints a subset of a markdown document
TypeScript
7
star
49

xode

Create a customized node binary with additional features
JavaScript
7
star
50

jsh

Tool for writing shell scripts using js.
JavaScript
7
star
51

clip-studio-paint-joycon

Use a Nintendo Switch Joy-Con (L) for Clip Studio Paint hotkeys
JavaScript
7
star
52

pretty-print-error

Formats errors as nice strings with colors
TypeScript
6
star
53

tf2-server-docker

TF2 Server running in a docker container
Dockerfile
6
star
54

kame

A JavaScript bundler/runtime
JavaScript
6
star
55

suchipi-game-controller

Wrapper around the Web Gamepad API
JavaScript
6
star
56

sheep-herder

A game where you're a dog who herds sheep
TypeScript
6
star
57

pokemon-red-intro-recreation

I recreated the Pokemon Red Intro in the browser
TypeScript
6
star
58

yosh

yavascript-based terminal shell
TypeScript
6
star
59

hex-engine-example-hexagon-grid

Example of rendering and interacting with a hexagonal grid in hex engine.
TypeScript
6
star
60

spotify-player

Node API to drive a Spotify browser window
JavaScript
6
star
61

webview-packager

Bundles your web app into a light, native desktop webview application.
JavaScript
6
star
62

lurantis

on-demand module bundler thingy
TypeScript
6
star
63

typescript-assert-utils

Utility types for making assertions about TypeScript types
TypeScript
6
star
64

pretty-print-ast

Formats ASTs as nice readable strings, with colors
TypeScript
5
star
65

commonjs-standalone

Standalone CommonJS loader for any JS engine
JavaScript
5
star
66

hex-engine-tic-tac-toe-example

An example tic-tac-toe game written in Hex Engine.
TypeScript
5
star
67

aseprite-loader

Webpack loader for *.ase/*.aseprite files
JavaScript
5
star
68

js-is

Functions for testing the types of JavaScript values, cross-realm. Has testers for all standard built-in objects/values.
TypeScript
5
star
69

get-nested-bounding-client-rect

Get the bounding client rect of an element relative to the root document, going through iframes
JavaScript
5
star
70

peep-the-horror

A toy you can use to make an impromptu soundboard out of a video (YouTube, etc).
TypeScript
4
star
71

reverse-proxy-cli

barebones reverse-proxy CLI for forwarding requests from one place to another
JavaScript
4
star
72

node-apng2gif

Convert APNG images to GIF
JavaScript
4
star
73

code-preview-from-error

Preview the code an Error came from
JavaScript
4
star
74

clefairy

Typed CLI argv parser and main function wrapper
TypeScript
4
star
75

at-js

Unix command-line utilities for working with data
JavaScript
4
star
76

girlboss-advance

(wip) remote multiplayer gba emulator web interface
Dockerfile
4
star
77

ac-toolkit

Elements that assist in creating animal-crossing-like UI experiences
HTML
4
star
78

midi-to-thirty-dollar-haircut

(bad) script that converts midi files into *.πŸ—Ώ files for https://gdcolon.com/%F0%9F%97%BF
TypeScript
4
star
79

tts-repl

simple interactive text-to-speech CLI program (shells out to AWS CLI for Amazon Polly and plays with ffplay)
TypeScript
4
star
80

js-sandbox-demo

Using QuickJS to implement a sandbox wherein it's safe to execute untrusted JavaScript code.
TypeScript
4
star
81

join

tiny CLI tool: JSON array to stdin -> join with delimiter -> string to stdout
Shell
4
star
82

visualize-ansi-codes

Replace ansi escape sequences with tokens indicating what they are.
TypeScript
3
star
83

extname

command-line utility for finding the extension of a filename/filepath
C
3
star
84

docker-bgb

BGB (GB/GBC Emulator) in docker via web vnc client (no sound)
Dockerfile
3
star
85

jest-node-nw-example

Jest in NW.js
JavaScript
3
star
86

defer

Inside-out promise; lets you call resolve and reject from outside the Promise constructor function.
TypeScript
3
star
87

shinobi

generate ninja build files from js scripts
TypeScript
3
star
88

babel-compare

Compare compilation output between babel 6 and babel 7
TypeScript
3
star
89

modal-synthesis

Package for synthesizing modal sounds using the Web Audio API
TypeScript
3
star
90

workspace-builder

CLI tool that run builds for every yarn workspace in your monorepo
JavaScript
3
star
91

first-base

Integration testing for CLI applications
JavaScript
3
star
92

discord-spotify-bot

Discord bot that plays Spotify in the voice channel
JavaScript
3
star
93

pipe-wrench

Generic, cross-platform IPC streams. Uses named pipes on windows and unix sockets elsewhere
JavaScript
3
star
94

bibarel

walk over a tree, transforming each value
TypeScript
3
star
95

a-mimir

Barebones sleep functions. As simple and boring as it gets
JavaScript
3
star
96

qjsbundle

TypeScript
3
star
97

link-fixer-discord-bot

Discord Bot: when someone posts a message with a twitter.com or x.com link, it replies with a vxtwitter.com version of that link
TypeScript
3
star
98

multi

Redux + Quake-style Netcode = ???
TypeScript
2
star
99

domdom-go

Cross-platform CLI interface to DomDomSoft Anime Downloader
Go
2
star
100

suchipis-color-theme

VSCode color theme
2
star