UNPKG

omnimatch

Version:

TypeScript tagged-union utility knife

290 lines (220 loc) 7.63 kB
# Omnimatch TypeScript tagged-union utility-knife ```typescript 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: ```typescript 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: ```typescript 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: ```typescript 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. ```typescript 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: ```typescript 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 ```typescript 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. ```typescript 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. ```typescript 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. ```typescript 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](./LICENSE) file.