1.0.0-development.2 • Published 4 years ago

omnimatch v1.0.0-development.2

Weekly downloads
2
License
MIT
Repository
github
Last release
4 years ago

Omnimatch

TypeScript tagged-union utility-knife

const result = match(expresion, {
  Number: ({ value }) => value,
  Add: ({ addends }) => addends.map(evaluate).reduce((l, r) => l + r),
  Subtract: ({ left, right }) => evaluate(left) - evaluate(right),
  Multiply: ({ multiplicands }) => multiplicands.map(evaluate).reduce((l, r) => l * r),
  Divide: ({ dividend, divisor }) => evaluate(dividend) / evaluate(divisor)
});

⚠️ Warning: This package is in development and has not been thoroughly tested. It may cause:

  • bizarre type errors
  • long compilation times

You should not use this package without a type-checker. It relies heavily on type-checking from TypeScript to ensure that usage of the library is correct. There is absolutely no runtime validation in this library.

About

Omnimatch works with any discriminant and any set of discriminant values, even unbounded ones. The only restriction is that the discriminant values be valid index types (in other words: string | number).

Omnimatch leverages the object-literal syntax of JavaScript to provide an experience similar to match or case expressions in functional programming languages.

It provides very strong type-checking. It can infer:

  • the types of the parameters within the match arm
  • the return type of the match function (the union of all possible return types of the match arms)

It allows for destructuring tagged unions in a huge variety of scenarios. For example, the following sections contain some more advanced usage.

Install

Install the package using npm:

npm install omnimatch@1.0.0-development.1

Import the required functions:

import { factory, match } from "omnimatch";

Why

Rust, ML, and other languages have fancy match/case expressions. In TypeScript, we have strongly-typed tagged unions like below:

interface VariantA {
    kind: "A";
    foo: number;
}

interface VariantB {
    kind: "B";
    bar: string;
}

type AB = VariantA | VariantB;

If you have a lot of variants, it's common to use a helper function to destructure these values that looks like this:

interface ABPattern<T> {
  A: (a: A) => T,
  B: (b: B) => T
}

function matchAB<T>(input: AB, pattern: ABPattern<T>) : T {
  return (pattern as any)[input.kind](input);
}

declare const ab : AB;

const x: number = matchAB(ab, {
  A: ({ foo }) => foo,
  B: ({ bar }) => +bar
});

Use

Match

This redundancy can get onerous, especially if you have a lot of tagged unions. Omnimatch provides a single match function that will work for any discriminated union, using any discriminant property, and any set of discriminant values. It provides strong type-checking and inference.

import { match } from "omnimatch";

declare const ab : AB;

// Type of x and of the pattern are inferred
const x = match(ab, {
  A: (a) => a.foo,
  B: (b) => +b.bar
});

Factory

Similarly, it can become tiring to type ... kind: "A" ... many times when fabricating new variants, so Omnimatch also provides a factory function that can assist in the creation of variants:

import { factory } from "omnimatch";

const make = factory<AB>();

const a : A = make.A({ foo: 10 });

The properties of the make object returned from factory are strongly-typed and will automatically add the kind property.

Unions with Multiple Variants Having the Same Discriminant

interface A {
  kind: "A";
  foo: string;
}

interface B {
  kind: "B";
  bar: number;
  funnyProperty?: undefined;
}

interface FunnyB {
  kind: "B";
  bar: number;
  funnyProperty: string;
}

declare const ab: A | B | FunnyB;

match(ab, {
  A: (a) => a.foo,
  // Type of `b` below is refined to `B | FunnyB`
  B: (b) => b.funnyProperty ? b.funnyProperty : "" + b.bar
});

Overriding the Discriminant

Omnimatch uses "kind" as its default disciminant. The third positional argument to match overrides this behavior. Strong type-checking will work regardless of the choice of discriminant.

interface Dillo {
  category: "animal",
  subcategory: "mammal",
  weight: number,
  color: string
}

interface Shark {
  category: "animal",
  subcategory: "fish",
  confirmedKills: number
}

interface FlyTrap {
  category: "plant",
  confirmedKills: number
}

declare const thing: Dillo | Shark | FlyTrap;

match(thing, {
  "animal": (ani: Dillo | Shark) => ...,
  "plant": (flytrap) => ...,
}, "category"); // Override discriminant in final argument

Unbounded discriminants

Type-checking bounds are still as strong as possible, even when the discriminant value is unbounded. This could be useful if you don't precisely know the variation in a field, but still want to handle some different cases.

type UnboundedKind = {
    kind: string
} & ({
    foo: number
} | {
    bar: string
});

declare const ubKind: UnboundedKind;

// Still allowed. Return type will always be inferred as optional,
// since the variation of "kind" is unconstrained
match(ubKind, {
  "someKind": (x) => {
    console.log("Got a value with \"someKind\", but nothing else is known!");
    return 0;
  },
  "someOtherKind": (x) => {
    console.log("This time, we got a \"someOtherKind\", but I still don't know anything else.");
    return 1;
  }
});

Tuple Unions (numerical index discriminant)

S-Expressions are the primitive syntax of Lisp-like programming languages.

Omnimatch can be used to destructure them by modeling them as strong tuple types, which can be discriminated just as well as unions of interfaces.

type Expression = SExpression | Atom;

type Atom = number | string;

type SExpression = Add | Sub | Mul | Div | Let;

type Add = ["+", ...Expression[]];
type Sub = ["-", Expression, ...Expression[]];
type Mul = ["*", ...Expression[]];
type Div = ["/", Expression, Expression];
type Let = ["let", [string, Expression], Expression];

function evaluate(x: Expression, env: { [k: string]: number } = {}): number {
    if (typeof x === "number") {
        return x;
    } else if (typeof x === "string") {
        // Lookup name
        if (env[x] !== undefined) {
            return env[x];
        } else throw new Error("No such binding for " + x);
    } else {
        // boundEval: evaluate in the scope of env, useful for map/reduce
        const boundEval = (e: Expression) => evaluate(e, env);
        // Core use of match here: take note of the discriminator
        return match(x, {
            "+": ([_, ...args]: Add) => args.map(boundEval).reduce((l, r) => l + r),
            "*": ([_, ...args]: Mul) => args.map(boundEval).reduce((l, r) => l * r),
            "-": ([_, left, ...right]: Sub) => {
                // Support a unary -
                if (right.length <= 0) {
                    return -boundEval(left);
                } else {
                    return boundEval(left) - right.map(boundEval).reduce((l, r) => l + r);
                }
            },
            "/": ([_, left, right]: Div) => boundEval(left) / boundEval(right),
            // eval body in the env, after extending it with the new name
            "let": ([_, [name, value], body]: Let) => evaluate(body, { ...env, [name]: boundEval(value) }),
        }, 0); // 0 as discriminator is read "first item of the tuple"
    }
}

console.assert(
    evaluate(["let", ["x", ["+", 100, 31]], ["/", "x", 15]]) - 8.7333 <= 0.001,
    "Evaluation did not have expected result."
)

License

Omnimatch is licensed under the MIT license. See the included LICENSE file.