diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 603a6bc..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -# Patch 0.9.12 - -Changes to Option/Result methods: - -- `is` has been deprecated and replaced by `isLike`. -- `eq` has been deprecated and replaced by `equals`. -- `neq` has been deprecated. - -# Patch 0.9.11 - -Fix publishing mistake. - -# Patch 0.9.10 - -Deprecated guarded functions. - -# Patch 0.9.8 - -Added `.all`, `.any` and `.safe` methods to `Option` and `Result`. Complete -with tests and usage examples. - -# Patch 0.9.7 - -After lots of great feedback, I've decided that the `snake_case` API will be -removed in the 1.0 release. Patch 0.9.7 moves `camelCase` to the front seat -without breaking anything. diff --git a/README.md b/README.md index e4f1074..2a2b71b 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,57 @@ # oxide.ts [Rust](https://rust-lang.org)'s `Option` and `Result`, implemented -for TypeScript. +for TypeScript. Zero dependencies, full test coverage and complete in-editor documentation. -## Features +## Release 1.0 -Zero dependencies, full test coverage and examples for every function at your -fingertips with JSDoc comments. +Release 1.0 includes new features, removes old features and has breaking +changes. -- Add more meaning to return types. -- Express, chain and map values as if you were writing in Rust. -- Use the `match` adaptation to simplify conditionals. -- Quickly test multiple Results or Options with `.all` and `.any`. -- Convert throws and rejections into Results and Options with `.safe`. +[Check out what's new in 1.0](#new-in-10). -## Patch 0.9.10 - -- ~~Make guarded functions that return at the first sign of trouble (`?`).~~ -- ~~API available both `snake_case` and `camelCase`.~~ - -# Installation +## Installation ``` $ npm install oxide.ts --save ``` -# Usage - -The the best documentation is in the JSDoc and tests directory, there are -several examples there not covered in this Readme. If you're using VSCode -you should also be able to hover over methods to see some examples. +## Usage ### Core Features -- [Option type](#option) -- [Result type](#result) -- [Transformation](#transformation) +- [Importing](#importing) +- [Option](#option) +- [Result](#result) +- [Converting](#converting) - [Nesting](#nesting) +- [Iteration](#iteration) +- [Safe](#safe) - [All](#all) - [Any](#any) -- [Match](#match) -### Advanced Features +### Advanced -- [Word to the wise](#word-to-the-wise) -- [Safe functions and Promises](#safe) -- [Combined Match](#combined-matching) -- [Match Chains](#chained-matching) +- [Match](#match) +- [Combined Match](#combined-match) +- [Match Chains](#chained-match) +- [Compiling](#compiling) + +## Importing -### Tests +You can import the complete **oxide.ts** library: +```ts +import { Option, Some, None, Result, Ok, Err, match, Fn, _ } from "oxide.ts"; ``` -npm run test + +Or just the **core** library, which exclues the `match` feature: + +```ts +import { Option, Some, None, Result, Ok, Err } from "oxide.ts/core"; ``` -# Option +## Option An Option represents either something, or nothing. If we hold a value of type `Option`, we know it is either `Some` or `None`. Both types share a common API, so we can chain operations without having to worry whether we have @@ -64,28 +61,24 @@ Some or None until pulling the value out: import { Option, Some, None } from "oxide.ts"; function divide(x: number, by: number): Option { - if (by === 0) { - return None; - } else { - return Some(x / by); - } + return by === 0 ? None : Some(x / by); } const val = divide(100, 20); // Pull the value out, or throw if None: const res: number = val.unwrap(); -// Throw our own error message in the case of None: -const res: number = val.expect("Division Failed"); +// Throw a custom error message in the case of None: +const res: number = val.expect("Don't divide by zero!"); // Pull the value out, or use a default if None: const res: number = val.unwrapOr(1); // Map the Option to Option by applying a function: -const strval: Option = val.map((num) => `Result = ${num}`); -// Then unwrap the value or use a default if None: -const res: string = strval.unwrapOr("Error"); +const strval: Option = val.map((num) => `val = ${num}`); +// Unwrap the value or use a default if None: +const res: string = strval.unwrapOr("val = "); // Map, assign a default and unwrap in one line: -const res: string = val.mapOr("Error", (num) => `Result = ${num}`); +const res: string = val.mapOr("val = ", (num) => `val = ${num}`); ``` _The type annotations applied to the const variables are for information -_ @@ -93,7 +86,7 @@ _the correct types would be inferred._ [« To contents](#usage) -# Result +## Result A Result represents either something good (`T`) or something not so good (`E`). If we hold a value of type `Result` we know it's either `Ok` or @@ -103,143 +96,192 @@ If we hold a value of type `Result` we know it's either `Ok` or import { Result, Ok, Err } from "oxide.ts"; function divide(x: number, by: number): Result { - if (by === 0) { - return Err("Division Failed"); - } else { - return Ok(x / by); - } + return by === 0 ? Err("Division by zero") : Ok(x / by); } const val = divide(100, 20); // These are the same as Option (as are many of the other methods): const res: number = val.unwrap(); -const res: number = val.expect("Division Failed"); +const res: number = val.expect("Don't divide by zero!"); const res: number = val.unwrapOr(1); -// Map Result to Result, similar to mapping Option to Option -const strval: Result = val.map((num) => `Result = ${num}`); -const res: string = strval.unwrapOr("Error"); -const res: string = val.mapOr("Error", (num) => `Result = ${num}`); -// We can unwrap the error, which throws if the Result is Ok: +// Map Result to Result +const strval: Result = val.map((num) => `val = ${num}`); +const res: string = strval.unwrapOr("val = "); +const res: string = val.mapOr("val = ", (num) => `val = ${num}`); + +// Unwrap or expect the Err (throws if the Result is Ok): const err: string = val.unwrapErr(); -const err: string = val.expectErr("Expected this to fail"); +const err: string = val.expectErr("Expected division by zero!"); -// Or map the error, mapping Result to Result -const objerr: Result = val.mapErr((message) => { - return new Error(message); -}); +// Or map the Err, converting Result to Result +const errobj: Result = val.mapErr((msg) => new Error(msg)); ``` -[« To contents](#usage) +## Converting + +These methods provide a way to jump in to (and out of) `Option` and `Result` +types. Particularly these methods can streamline things where: + +- A function returns `T | null`, `T | false` or similar. +- You are working with physical quantities or using an `indexOf` method. +- A function accepts an optional argument, `T | null` or similar. -# Transformation +**Note:** Converting to a Result often leaves you with a `Result`. +The null value here is not very useful - consider the equivalent Option method +to create an `Option`, or use `mapErr` to change the `E` type. -Because they are so similar, it's possible to transform an `Option` into a -`Result` and vice versa: +### into + +Convert an existing `Option`/`Result` into a union type containing `T` and +`undefined` (or a provided falsey value). ```ts -const val: Option = divide(100, 10); +function maybeName(): Option; +function maybeNumbers(): Result; +function printOut(msg?: string): void; + +const name: string | undefined = maybeName().into(); +const name: string | null = maybeName().into(null); -// Here, the argument provides the Err value to be used if val is None: -const res: Result = val.okOr("Division Error"); +// Note that the into type does not reflect the E type: +const numbers: number[] | undefined = maybeNumbers().into(); +const numbers: number[] | false = maybeNumbers().into(false); -// And to turn it back into an Option: -const opt: Option = res.ok(); +// As a function argument: +printOut(name.into()); ``` -_Note that converting from `Result` to `Option` causes the `Err`_ -_value (if any) to be discarded._ +### from -[« To contents](#usage) +Convert to an `Option`/`Result` which is `Some`/`Ok` unless the value is +falsey, an instance of `Error` or an invalid `Date`. -# Nesting +The `T` is narrowed to exclude any falsey values or Errors. + +```ts +const people = ["Fry", "Leela", "Bender"]; +// Create an Option from a find: +const person = Option.from(people.find((name) => name === "Fry")); +// or shorter: +const person = Option(people.find((name) => name === "Bender")); +``` -There is no reason you can't nest `Option` and `Result` structures. The -following is completely valid: +In the case of `Result`, the `E` type includes: + +- `null` (if `val` could have been falsey or an invalid date) +- `Error` types excluded from `T` (if there are any) + +```ts +function randomName(): string | false; +function tryName(): string | Error; +function randomNumbers(): number[] | Error; + +// Create a Result +const person = Result.from(randomName()); +// Create a Result +const name = Result(tryName()); +// Create a Result +const num = Result(randomNumbers()); +``` + +### nonNull + +Convert to an `Option`/`Result` which is `Some`/`Ok` unless the value +provided is `undefined`, `null` or `NaN`. ```ts -const res: Result, string> = Ok(Some(10)); -const val: number = res.unwrap().unwrap(); +function getNum(): number | null; +const num = Option.nonNull(getNum()).unwrapOr(100); // Could be 0 + +const words = ["express", "", "planet"]; +const str = Option.nonNull(words[getNum()]); +str.unwrapOr("No such index"); // Could be "" ``` -There are times when this makes sense, consider something like: +### qty + +Convert to an `Option`/`Result` which is which is `Some`/`Ok` +when the provided `val` is a finite integer greater than or equal to 0. ```ts -import { Result, Option, Some, None, Ok, Err, match } from "oxide.ts"; +const word = "Buggalo"; + +const g = Option.qty(word.indexOf("g")); +assert.equal(g.unwrap(), 2); + +const z = Option.qty(word.indexOf("z")); +assert.equal(z.isNone(), true); +``` + +[« To contents](#usage) + +## Nesting + +You can nest `Option` and `Result` structures. The following example uses +nesting to distinguish between _found something_, _found nothing_ and +_database error_: +```ts function search(query: string): Result, string> { const [err, result] = database.search(query); if (err) { return Err(err); } else { - return result.count > 0 ? Ok(Some(result)) : Ok(None); + return Ok(result.count > 0 ? Some(result) : None); } } const result = search("testing"); const output: string = match(result, { - Ok: match({ - Some: (res) => `Found ${res.count} entries.`, + Ok: { + Some: (result) => `Found ${result.count} entries.`, None: () => "No results for that search.", - }), - Err: (err) => `Error: ${err}`, + }, + Err: (err) => `Error: ${err}.`, }); ``` [« To contents](#usage) -# Match - -Concisely determine what action should be taken for a given input value. -For all the different ways you can use `match` (including the advanced uses -discussed later), the following rules apply: +## Iteration -- Every branch must have the same return type. -- As soon as a matching branch is found, no others are checked. +An `Option` or `Result` that contains an iterable `T` type can be iterated upon +directly. In the case of `None` or `Err`, an empty iterator is returned. -The most basic `match` can be performed on `Option` and `Result` types. This -is called _mapped_ matching. +The compiler will complain if the inner type is not definitely iterable +(including `any`), or if the monad is known to be `None` or `Err`. ```ts -const num: Option = Some(10); -const res = match(num, { - Some: (n) => n + 1, - None: () => 0, -}); +const numbers = Option([1.12, 2.23, 3.34]); +for (const num of numbers) { + console.log("Number is:", num.toFixed(1)); +} -assert.equal(res, 11); +const numbers: Option = None; +for (const num of numbers) { + console.log("Unreachable:", num.toFixed()); +} ``` -It's also possible to nest mapped matching and provide defaults. You don't -have to include every named branch: +It's also possible to iterate over nested monads in the same way: ```ts -const matchNest = (input: Result, string>) => - match(input, { - Ok: match({ - Some: (n) => `num ${n}`, - }), - _: () => "nothing", - }); - -assert.equal(matchNest(Ok(Some(10))), "num 10"); -assert.equal(matchNest(Ok(None)), "nothing"); -assert.equal(matchNest(Err("none")), "nothing"); +const numbers = Option(Result(Option([1, 2, 3]))); +for (const num of numbers) { + console.log("Number is:", num.toFixed(1)); +} ``` -**Note:** Using `match` without the first-position value is not a way to -"compile" a match function. Only call match like this within a nested -match structure. - [« To contents](#usage) -# Safe +## Safe Capture the outcome of a function or Promise as an `Option` or `Result`, preventing throwing (function) or rejection (Promise). -## Safe Functions +### Safe Functions Calls the passed function with the arguments provided and returns an `Option` or `Result`. The outcome is `Some`/`Ok` if the function @@ -267,7 +309,7 @@ rejected by the type signature. `Result, Error>` or `Option>` are not useful types - using it in this way is likely to be a mistake. -## Safe Promises +### Safe Promises Accepts a `Promise` and returns a new Promise which always resolves to either an `Option` or `Result`. The Result is `Some`/`Ok` if the original @@ -292,7 +334,7 @@ assert.equal(x.unwrap(), "Hello World"); [« To contents](#usage) -# All +## All Reduce multiple `Option`s or `Result`s to a single one. The first `None` or `Err` encountered is returned, otherwise the outcome is a `Some`/`Ok` @@ -316,7 +358,7 @@ assert.equal(err.unwrapErr(), "Value 5 is too low."); [« To contents](#usage) -# Any +## Any Reduce multiple `Option`s or `Result`s into a single one. The first `Some`/`Ok` found (if any) is returned, otherwise the outcome is `None`, or in the case of `Result` - an `Err` containing an array of all the unwrapped errors. @@ -338,120 +380,181 @@ assert.equal(g, "Value 8 is too low."); [« To contents](#usage) -# Advanced Features +## Match -## Word to the wise +Mapped matching is possible on `Option` and `Result` types: -The `match` adaptation shifts the TypeScript idiom and may not be suitable for your project - especially if you work with others. +```ts +const num = Option(10); +const res = match(num, { + Some: (n) => n + 1, + None: () => 0, +}); -### Combined Matching +assert.equal(res, 11); +``` -It's possible to combine the [mapped](#match) and [chained](#chained-matching) matching approach. +You can nest mapped matching patterns and provide defaults. If a default is +not found in the current level it will fall back to the previous level. When +no suitable match or default is found, an exhausted error is thrown. ```ts -import { Option, match } from "oxide.ts"; +function nested(val: Result, string>): string { + return match(val, { + Ok: { Some: (num) => `found ${num}` }, + _: () => "nothing", + }); +} + +assert.equal(nested(Ok(Some(10))), "found 10"); +assert.equal(nested(Ok(None)), "nothing"); +assert.equal(nested(Err("Not a number")), "nothing"); +``` + +[« To contents](#usage) + +## Combined Match + +[Mapped](#match) Matching and [Chained](#chained-match) Matching can be +combined. A match chain can be provided instead of a function for `Some`, +`Ok` and `Err`. -// Easiest to build upon -function player_allowed(player: Option): boolean { - return match(player, { +```ts +function matchNum(val: Option): string { + return match(val, { Some: [ - [{ status: "banned" }, false], - [{ age: (n) => n > 18 }, true], + [5, "5"], + [(x) => x < 10, "< 10"], + [(x) => x > 20, "> 20"], ], - _: () => false, + _: () => "none or not matched", }); } + +assert.equal(matchNum(Some(5)), "5"); +assert.equal(matchNum(Some(7)), "< 10"); +assert.equal(matchNum(Some(25)), "> 20"); +assert.equal(matchNum(Some(15)), "none or not matched"); +assert.equal(matchNum(None), "none or not matched"); ``` [« To contents](#usage) -## Chained Matching - -Can be performed on any type. A chain is an array of branches which are -tested in sequence. A branch is a tuple of [``, ``]. -Chain branches follow the following rules: - -- Primitive comparisons test for exact equality (`===`). -- Any comparison with the condition `_` (`Default`) succeeds automatically. -- Matching against arrays is a key-to-key comparison (just like objects). As - such, a match condition of `[10, 20]` doesn't check if 10 and 20 are in - the array, but instead checks specifically that index `0` is 10 and index - `1` is 20. -- Tuple elements are "functions first", such that any `` that is - a function will be called to determine if the branch matches, and any - `` that is a function is called with the input value to determine - the return value. To match or return a function, see `Fn`. -- On the matter of functions, a `` is always a sync function. - A `` can be async, but if so every branch must return an async - function. -- `Option` and `Result` types are recursively evaluated to their deepest - reachable values and evaluated like any other condition. Using mapped or - combined matching for these types is better. - -At the end of a chain, an optional default branch may be included which is -called with the input value when no other branch matches. If no default is -provided, `match` will throw an error if no other branch matches. - -**Note:** Deeply nesting `Option`/`Result` matches may not allow for -complete type information to be presented to the user (though they should -still be verified). It is also slower (execution time and type computation) -than mapped matching or combined matching. - -### Primitive Example +## Match Chains -```ts -import { match } from "oxide.ts"; +Chained matching is possible on any type. Branches are formed by associating +a `condition` with a `result` (with an optional default at the end). The first +matching branch is the result. -const matchNum = (num: number) => - match(num, [ - [5, "five"], - [(n) => n > 100, "big number"], - [(n) => n < 0, (n) => `negative ${n}`], +More detail about chained matching patterns is available in the bundled JSDoc. + +### Examples + +```ts +function matchArr(arr: number[]): string { + return match(arr, [ + [[1], "1"], + [[2, (x) => x > 10], "2, > 10"], + [[_, 6, 9, _], (a) => a.join(", ")], () => "other", ]); +} -assert.equal(matchNum(5), "five"); -assert.equal(matchNum(150), "big number"); -assert.equal(matchNum(-20), "negative -20"); -assert.equal(matchNum(50), "other"); +assert.equal(matchArr([1, 2, 3]), "1"); +assert.equal(matchArr([2, 12, 6]), "2, > 10"); +assert.equal(matchArr([3, 6, 9]), "other"); +assert.equal(matchArr([3, 6, 9, 12]), "3, 6, 9, 12"); +assert.equal(matchArr([2, 4, 6]), "other"); ``` -### Object Example - ```ts -import { match } from "oxide.ts"; +interface ExampleObj { + a: number; + b?: { c: number }; + o?: number; +} -const matchObj = (obj: { a: number; b: { c: number } }) => - match(obj, [ - [{ a: 5 }, "a is 5"], - [{ b: { c: 5 } }, "c is 5"], - [{ a: 10, b: { c: (n) => n > 10 } }, "a 10 c gt10"], +function matchObj(obj: ExampleObj): string { + return match(obj, [ + [{ a: 5 }, "a = 5"], + [{ b: { c: 5 } }, "c = 5"], + [{ a: 10, o: _ }, "a = 10, o = _"], + [{ a: 15, b: { c: (n) => n > 10 } }, "a = 15; c > 10"], () => "other", ]); +} -assert.equal(matchObj({ a: 5, b: { c: 5 } }), "a is 5"); -assert.equal(matchObj({ a: 50, b: { c: 5 } }), "c is 5"); -assert.equal(matchObj({ a: 10, b: { c: 20 } }), "a 10 c gt 10"); -assert.equal(matchObj({ a: 8, b: { c: 8 } }), "other"); +assert.equal(matchObj({ a: 5 }), "a = 5"); +assert.equal(matchObj({ a: 50, b: { c: 5 } }), "c = 5"); +assert.equal(matchObj({ a: 10 }), "other"); +assert.equal(matchObj({ a: 10, o: 1 }), "a = 10, o = _"); +assert.equal(matchObj({ a: 15, b: { c: 20 } }), "a = 15; c > 10"); +assert.equal(matchObj({ a: 8, b: { c: 8 }, o: 1 }), "other"); ``` -### Array Example +[« To contents](#usage) -```ts -import { match, _ } from "oxide.ts"; +## Compiling -const matchArr = (arr: number[]) => - match(arr, [ - [[1], "1"], - [[2, (n) => n > 10], "2 gt10"], - [[_, 6, _, 12], "_ 6 _ 12"], - () => "other", - ]); +Match patterns can also be _compiled_ into a function. More detail about +compiling is available in the bundled JSDoc. -assert.equal(matchArr([1, 2, 3]), "1"); -assert.equal(matchArr([2, 12, 6]), "2 gt10"); -assert.equal(matchArr([3, 6, 9, 12]), "_ 6 _ 12"); -assert.equal(matchArr([2, 4, 6]), "other"); +```ts +const matchSome = match.compile({ + Some: (n: number) => `some ${n}`, + None: () => "none", +}); + +assert.equal(matchSome(Some(1)), "some 1"); +assert.equal(matchSome(None), "none"); ``` [« To contents](#usage) + +## New in 1.0 + +### Features and Improvements + +- [From](#converting) concept with three new methods - [from](#from), + [nonNull](#nonnull) and [qty](#qty). +- New [safe](#safe), [all](#all) and [any](#any) methods (since 0.9.8). +- New methods on Option/Result - [into](#converting) and `filter`. +- Inline [iteration](#iteration) support. +- Type Improvements: + - `Ok` is now `Ok` + - `Err` is now `Err` + - `None` is now `None` +- Better type guards on `isSome`, `isOk` and `isErr`. +- New [compile](#compiling) method. +- Nested [mapped matching](#match) syntax improved. +- Mapped matching now allows for an `Option` | `Result` type union. +- Option to import only core library from **oxide.ts/core**. +- Performance improvements. + +### Breaking Changes + +- Removed `snake_case` (Rust-style) API. +- Removed [guarded functions](https://github.com/traverse1984/oxide.ts/blob/922d70a286b47d4b13efdb24662c6d81de2e29a5/README.md#guarded-option-function). +- Option/Result + - Removed `eq` and `neq` (briefly renamed `eq` to `equals` before removing). + - Renamed `is` to `isLike`. + - Improvements to monad types could in some cases cause compile errors. +- Match + - Mapped matching syntax has changed for nested matches. + - Removed `SomeIs`, `OkIs` and `ErrIs`. + - Using `_` to match in an Object/Array now requires the key be present. + - Functions within a monad (at any depth) are no longer called as filters + in match chains - they are always treated as values. +- Changes to internal structures and logic - should only cause issues if you + were doing something unusual. + +### Other stuff + +- Removed most uses of Object.freeze. +- Tidied up and improved documentation in lots of places. +- Tests are better organized and more tests added. +- Compiler target is now ES2021. + +[« To contents](#usage) + +[« To top of page](#oxidets) diff --git a/bench/bench.ts b/bench/bench.ts new file mode 100644 index 0000000..c7c480c --- /dev/null +++ b/bench/bench.ts @@ -0,0 +1,47 @@ +export function Bench( + name: string, + count: number, + testfn: (count: number) => any, + compare?: (count: number) => any +): void { + const { dur, iter } = test(count, testfn); + const cmp = compare ? test(count, compare) : null; + + let Duration = `${(dur / 1000).toFixed(3)}`; + let PerIter = `${iter.toFixed(3)}`; + + if (cmp) { + const { dur, iter } = cmp; + Duration += ` (vs ${(dur / 1000).toFixed(3)})`; + PerIter += ` (vs ${iter.toFixed(3)})`; + } + + console.log(name); + console.log(" Iterations ......", new Intl.NumberFormat().format(count)); + console.log(" Duration ........", Duration, "sec"); + console.log(" Per Iteration ...", PerIter, "micros"); + + if (cmp) { + const diff = cmp.dur - dur; + const pct = ((Math.abs(diff) / cmp.dur) * 100).toFixed(1); + const word = diff > 0 ? "faster" : "slower"; + console.log(" Difference ......", `${pct}%`, word); + } + + console.log(""); +} + +function test( + count: number, + fn: (count: number) => any +): { dur: number; iter: number } { + const start = new Date(); + for (let i = 0; i < count; i++) { + fn(i); + } + const end = new Date(); + const dur = end.getTime() - start.getTime(); + const iter = 1000 * (dur / count); + + return { dur, iter }; +} diff --git a/bench/tests/00-some.bench.ts b/bench/tests/00-some.bench.ts new file mode 100644 index 0000000..b863d1b --- /dev/null +++ b/bench/tests/00-some.bench.ts @@ -0,0 +1,4 @@ +import { Bench } from "../bench"; +import { Some } from "../../src"; + +Bench("Create Some", 10000000, (i) => Some(i)); diff --git a/bench/tests/01-ok.bench.ts b/bench/tests/01-ok.bench.ts new file mode 100644 index 0000000..e23e2b1 --- /dev/null +++ b/bench/tests/01-ok.bench.ts @@ -0,0 +1,4 @@ +import { Bench } from "../bench"; +import { Ok } from "../../src"; + +Bench("Create Ok", 10000000, (i) => Ok(i)); diff --git a/bench/tests/02-err.bench.ts b/bench/tests/02-err.bench.ts new file mode 100644 index 0000000..304bef1 --- /dev/null +++ b/bench/tests/02-err.bench.ts @@ -0,0 +1,4 @@ +import { Bench } from "../bench"; +import { Err } from "../../src"; + +Bench("Create Err", 10000000, (i) => Err(i)); diff --git a/bench/tests/03-nested.bench.ts b/bench/tests/03-nested.bench.ts new file mode 100644 index 0000000..c4a0ed0 --- /dev/null +++ b/bench/tests/03-nested.bench.ts @@ -0,0 +1,19 @@ +import { Bench } from "../bench"; +import { Some, Ok, Err } from "../../src"; + +Bench("Monads (Deep + Unwrap)", 5000000, (i) => { + Some(Ok(Err(Ok(Some(Ok(Err(Ok(Some(Ok(Err(Ok(Some(i))))))))))))) + .unwrap() + .unwrap() + .unwrapErr() + .unwrap() + .unwrap() + .unwrap() + .unwrapErr() + .unwrap() + .unwrap() + .unwrap() + .unwrapErr() + .unwrap() + .unwrap(); +}); diff --git a/bench/tests/10-match.bench.ts b/bench/tests/10-match.bench.ts new file mode 100644 index 0000000..dc47860 --- /dev/null +++ b/bench/tests/10-match.bench.ts @@ -0,0 +1,41 @@ +import { Bench } from "../bench"; +import { match } from "../../src"; + +Bench("Simple Match (vs if/else)", 50000000, library, native); +Bench("Simple Match Compiled (vs if/else)", 50000000, compiled(), native); + +function library(i: number) { + return match(i, [ + [1, "1"], + [2, "2"], + [3, "3"], + [(n) => n < 5000, "<5000"], + () => "default", + ]); +} + +function compiled(): (i: number) => string { + const matchNum = match.compile([ + [1, "1"], + [2, "2"], + [3, "3"], + [(n) => n < 5000, "<5000"], + () => "default", + ]); + + return matchNum; +} + +function native(i: number) { + if (i === 1) { + return "number 1"; + } else if (i === 2) { + return "number 2"; + } else if (i === 3) { + return "number 3"; + } else if (i < 5000) { + return "<5000"; + } else { + return "default"; + } +} diff --git a/bench/tests/11-compile.bench.ts b/bench/tests/11-compile.bench.ts new file mode 100644 index 0000000..0d6524b --- /dev/null +++ b/bench/tests/11-compile.bench.ts @@ -0,0 +1,95 @@ +import { Bench } from "../bench"; +import { Option, Some, Result, Ok, match } from "../../src"; + +Bench("Match (Compiled vs Not Compiled)", 1000000, compiled(), inline); + +function inline(i: number) { + match(i, [ + [50, "50"], + [100, "100"], + [100000, "100000"], + [(n) => n > 50000, ">500"], + () => "default", + ]); + + const res = Ok(Option.from(i)); + match(res, { + Ok: { + Some: [ + [50, "50"], + [100, "100"], + [100000, "100000"], + [(n) => n < 5000, "<5000"], + ], + }, + _: () => "default", + }); + + match({ i, j: i % 100 }, [ + [{ i: 1 }, "i=1"], + [{ j: 1 }, "j=1"], + [{ i: (n) => n < 5000 }, "i<5000"], + [{ j: (n) => n > 90 }, "j>90"], + [{ i: (n) => n > 20000, j: (n) => n < 50 }, "i>20k;j<50"], + () => "default", + ]); + + match(Some({ i: Option.from(i) }), { + Some: [ + [{ i: Some(1) }, "1"], + [{ i: Some(10) }, "10"], + [{ i: Some(100) }, "100"], + ], + _: () => "default", + }); +} + +function compiled(): (i: number) => void { + const matchNum = match.compile([ + [50, "50"], + [100, "100"], + [100000, "100000"], + [(n: number) => n > 50000, ">500"], + () => "default", + ]); + + const matchMonad = match.compile, any>, string>({ + Ok: { + Some: [ + [50, "50"], + [100, "100"], + [100000, "100000"], + [(n) => n < 5000, "<5000"], + ], + }, + _: () => "default", + }); + + const matchObject = match.compile<{ i: number; j: number }, string>([ + [{ i: 1 }, "i=1"], + [{ j: 1 }, "j=1"], + [{ i: (n) => n < 5000 }, "i<5000"], + [{ j: (n) => n > 90 }, "j>90"], + [{ i: (n) => n > 20000, j: (n) => n < 50 }, "i>20k;j<50"], + () => "default", + ]); + + const matchMonadInObject = match.compile< + Option<{ i: Option }>, + string + >({ + Some: [ + [{ i: Some(1) }, "1"], + [{ i: Some(10) }, "10"], + [{ i: Some(100) }, "100"], + ], + _: () => "default", + }); + + return (i: number) => { + matchNum(i); + matchMonad(Ok(Option.from(i))); + matchObject({ i, j: i % 100 }); + matchMonadInObject(Some({ i: Option.from(i) })); + }; +} diff --git a/bench/tsconfig.json b/bench/tsconfig.json new file mode 100644 index 0000000..54fdf7d --- /dev/null +++ b/bench/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["."] +} diff --git a/package.json b/package.json index f49cbde..ef525b7 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,20 @@ { "name": "oxide.ts", - "version": "0.9.12", + "version": "1.0.0", "description": "Rust's Option and Result, implemented for TypeScript.", "main": "dist", "types": "dist", + "exports": { + ".": "./dist/index.js", + "./core": "./dist/core.js" + }, "files": [ "dist/**/*" ], "scripts": { - "test": "nyc mocha -r ts-node/register 'tests/**/*'" + "test": "nyc mocha -r ts-node/register 'tests/**/*'", + "bench": "for file in bench/tests/*.bench.ts; do ts-node $file; done", + "build": "tsc" }, "keywords": [ "rust", @@ -27,15 +33,15 @@ }, "dependencies": {}, "devDependencies": { - "@types/chai": "^4.3.0", + "@types/chai": "^4.3.1", "@types/mocha": "^9.1.0", - "@typescript-eslint/eslint-plugin": "^5.10.2", - "@typescript-eslint/parser": "^5.10.2", - "typescript": "^4.5.5", - "eslint": "^8.8.0", - "mocha": "^9.2.0", + "@typescript-eslint/eslint-plugin": "^5.19.0", + "@typescript-eslint/parser": "^5.19.0", "chai": "^4.3.6", + "eslint": "^8.13.0", + "mocha": "^9.2.2", "nyc": "^15.1.0", - "ts-node": "^10.5.0" + "ts-node": "^10.7.0", + "typescript": "^4.6.3" } } diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..e23754f --- /dev/null +++ b/src/common.ts @@ -0,0 +1,20 @@ +/** + * Unique marker for `Option` and `Result` types. + * + * ### Warning + * This library sometimes assumes a value with this key is an Option or Result + * without explicitly checking the instance type or other properties. + */ +export const T = Symbol("T"); +export const Val = Symbol("Val"); +export const FnVal = Symbol("FnVal"); +export const EmptyArray = Object.freeze([] as any[]); + +export type FalseyValues = false | null | undefined | 0 | 0n | ""; +export function isTruthy(val: unknown): boolean { + return val instanceof Date ? val.getTime() === val.getTime() : !!val; +} + +export type IterType = T extends { [Symbol.iterator](): infer I } + ? I + : unknown; diff --git a/src/core.ts b/src/core.ts new file mode 100644 index 0000000..4526da1 --- /dev/null +++ b/src/core.ts @@ -0,0 +1,2 @@ +export { Option, Some, None } from "./option"; +export { Result, Ok, Err } from "./result"; diff --git a/src/index.ts b/src/index.ts index cc8c92f..2bde69c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export { Option, Some, None } from "./option"; -export { Result, Ok, Err, ResultGuard, ResultGuard as Guard } from "./result"; -export { match, _, Fn, SomeIs, OkIs, ErrIs, Default } from "./match"; +export { Result, Ok, Err } from "./result"; +export { match, Fn, Default, _ } from "./match"; diff --git a/src/match.ts b/src/match.ts index c3b2069..7bf9976 100644 --- a/src/match.ts +++ b/src/match.ts @@ -1,59 +1,62 @@ -import { Option } from "./option"; +import { T, Val, FnVal } from "./common"; +import { Option, Some, None } from "./option"; import { Result, Ok, Err } from "./result"; -type Mapped = (val: T) => U; -type AnyMonadic = Option | Result; -type Branches = Branch[] | [...Branch[], Mapped]; -type Branch = [BranchCondition, BranchResult]; -type BranchResult = U | ((val: T) => U); +type MappedBranches = + | (T extends Option ? OptionMapped : never) + | (T extends Result ? ResultMapped : never); + +type ChainedBranches = + | Branch[] + | [...Branch[], DefaultBranch]; type BranchCondition = | Mapped - | (T extends AnyMonadic ? MonadCondition : PrimitiveCondition); + | (T extends { [T]: boolean } ? MonadCondition : Condition); + +type Branch = [BranchCondition, BranchResult]; +type Mapped = (val: T) => U; +type Wide = T extends [...infer U] ? U[number][] : Partial; +type BranchResult = U | ((val: T) => U); +type DefaultBranch = () => U; + +interface OptionMapped { + Some?: MonadMapped; + None?: DefaultBranch; + _?: DefaultBranch; +} + +interface ResultMapped { + Ok?: MonadMapped; + Err?: MonadMapped; + _?: DefaultBranch; +} -type PrimitiveCondition = T extends object +type Condition = T extends object ? { [K in keyof T]?: BranchCondition } : T; type MonadCondition = T extends Option - ? Option> + ? Some> | None : T extends Result - ? Ok, any> | Err, any> - : Partial; - -type OptionBranches = Branches, U>; - -interface MappedOption { - Some?: Mapped | Branches; - None?: Mapped; - Ok?: never; - Err?: never; - _?: (val: T | Default) => U; -} - -type ResultBranches = Branches, U>; + ? Ok> | Err> + : Wide; -interface MappedResult { - Ok?: Mapped | Branches; - Err?: Mapped | Branches; - Some?: never; - None?: never; - _?: (val: T | E) => U; -} +type MonadMapped = + | Mapped + | ChainedBranches + | MappedBranches; /** * Concisely determine what action should be taken for a given input value. - * Of all the different ways you can use `match`, the following rules are - * always true: - * - * * Every branch must have the same return type. - * * As soon as a matching branch is found, no others are checked. * * ### Mapped Matching - * Can be performed on `Option` and `Result` types. + * + * Mapped matching is possible on `Option` and `Result` types. Passing any + * other type will throw an invalid pattern error. * * ``` - * const num: Option = Some(10); + * const num = Option(10); * const res = match(num, { * Some: (n) => n + 1, * None: () => 0, @@ -62,347 +65,452 @@ interface MappedResult { * assert.equal(res, 11); * ``` * - * It's also possible to nest mapped matching and provide a higher-level - * default. You don't have to include every named branch: + * You can nest mapped matching patterns and provide defaults. If a default is + * not found in the current level it will fall back to the previous level. When + * no suitable match or default is found, an exhausted error is thrown. * * ``` - * const matchNest = (input: Result, string>) => - * match(input, { - * Ok: match({ - * Some: (n) => `num ${n}`, - * }), - * _: () => "nothing", - * }); + * function nested(val: Result, string>): string { + * return match(val, { + * Ok: { Some: (num) => `found ${num}` }, + * _: () => "nothing", + * }); + * } * - * assert.equal(matchNest(Ok(Some(10))), "num 10"); - * assert.equal(matchNest(Ok(None)), "nothing"); - * assert.equal(matchNest(Err("none")), "nothing"); + * assert.equal(nested(Ok(Some(10))), "found 10"); + * assert.equal(nested(Ok(None)), "nothing"); + * assert.equal(nested(Err("Not a number")), "nothing"); * ``` - * **Note:** Using `match` without the first-position value is not a way to - * "compile" a match function. Only call match like this within a nested - * match structure. + * + * ### Combined Matching + * + * Mapped Matching and Chained Matching can be combined. A match chain can be + * provided instead of a function for `Some`, `Ok` and `Err`. E.g. + * + * ``` + * function matchNum(val: Option): string { + * return match(val, { + * Some: [ + * [5, "5"], + * [(x) => x < 10, "< 10"], + * [(x) => x > 20, "> 20"], + * ], + * _: () => "none or not matched", + * }); + * } + * + * assert.equal(matchNum(Some(5)), "5"); + * assert.equal(matchNum(Some(7)), "< 10"); + * assert.equal(matchNum(Some(25)), "> 20"); + * assert.equal(matchNum(Some(15)), "none or not matched"); + * assert.equal(matchNum(None), "none or not matched"); + * ``` + * + * ### Async + * + * A `condition` is always a sync function. The `result` can be an async + * function, providing that all branches return an async function. * * ### Chained Matching - * Can be performed on any type. A chain is an array of branches which are - * tested in sequence. A branch is a tuple of `[, ]`. - * Chain branches follow the following rules: - * - * * Primitive comparisons test for exact equality (`===`). - * * Any comparison with the condition `_` (`Default`) succeeds automatically. - * * Matching against arrays is a key-to-key comparison (just like objects). As - * such, a match condition of `[10, 20]` doesn't check if 10 and 20 are in - * the array, but instead checks specifically that index `0` is 10 and index - * `1` is 20. - * * Tuple elements are "functions first", such that any `` that is - * a function will be called to determine if the branch matches, and any - * `` that is a function is called with the input value to determine - * the return value. To match or return a function, see `Fn`. - * * On the matter of functions, a `` is always a sync function. - * A `` can be async, but if so every branch must return an async - * function. - * * `Option` and `Result` types are recursively evaluated to their deepest - * reachable values and evaluated like any other condition. - * - * At the end of a chain, an optional default branch may be included which is - * called with the input value when no other branch matches. If no default is - * provided, `match` will throw an error if no other branch matches. - * - * **Note:** Deeply nesting `Option`/`Result` matches may not allow for - * complete type information to be presented to the user (though they should - * still be verified). It is also slower (execution time and type computation) - * than mapped matching or combined matching. + * + * Chained matching is possible on any type. Branches are formed by associating + * a `condition` with a `result`, and the chain is an array of branches. The + * last item in a chain may be a function (called to determine the default + * result when no branches match). + * + * A `condition` can be a: + * - primitive (to test for equality) + * - filter function which returns a boolean (to use a custom test) + * - partial object/array of `conditions` (to test for matching keys) + * - `Some`, `Ok` or `Err` containing a `condition` which is not a filter + * function (and which does not included a nested filter function). + * - function wrapped with `Fn` (to test for equality) + * - `_` or `Default` (to match any value at this position) + * + * A `result` can be: + * - any non-function value to be used as the result + * - a function which returns the result when called + * - a function wrapped with `Fn` to be used as the result + * + * If no branch matches and there is no default available, an exhausted error + * is thrown. + * + * #### Primitive + * + * The branch succeeds if the `condition` is strictly equal to the provided + * value. * * ``` - * // Primitives - * const matchNum = (num: number) => - * match(num, [ + * function matchNum(num: number): string { + * return match(num, [ * [5, "five"], - * [(n) => n > 100, "big number"], - * [(n) => n < 0, (n) => `negative ${n}`], + * [10, "ten"], + * [15, (x) => `fifteen (${x})`], // result function * () => "other", * ]); + * } * * assert.equal(matchNum(5), "five"); - * assert.equal(matchNum(150), "big number"); - * assert.equal(matchNum(-20), "negative -20"); - * assert.equal(matchNum(50), "other"); - * - * // Objects - * const matchObj = (obj: { a: number; b: { c: number } }) => - * match(obj, [ - * [{ a: 5 }, "a is 5"], - * [{ b: { c: 5 } }, "c is 5"], - * [{ a: 10, b: { c: (n) => n > 10 } }, "a 10 c gt10"], + * assert.equal(matchNum(10), "ten"); + * assert.equal(matchNum(15), "fifteen (15)"); + * assert.equal(matchNum(20), "other"); + * ``` + * + * #### Filter Function + * + * The branch succeeds if the `condition` returns true. + * + * ``` + * function matchNum(num: number): string { + * return match(num, [ + * [5, "five"], // Primitive Match + * [(x) => x < 20, "< 20"], + * [(x) => x > 30, "> 30"], + * () => "other", + * ]); + * } + * + * assert.equal(matchNum(5), "five"); + * assert.equal(matchNum(15), "< 20"); + * assert.equal(matchNum(50), "> 30"); + * assert.equal(matchNum(25), "other"); + * ``` + * + * #### Object + * + * The branch succeeds if all the keys in `condition` match those in the + * provided value. Using `_` allows any value (even undefined), but the key + * must still be present. + * + * + * ``` + * interface ExampleObj { + * a: number; + * b?: { c: number }; + * o?: number; + * } + * + * function matchObj(obj: ExampleObj): string { + * return match(obj, [ + * [{ a: 5 }, "a = 5"], + * [{ b: { c: 5 } }, "c = 5"], + * [{ a: 10, o: _ }, "a = 10, o = _"], + * [{ a: 15, b: { c: (n) => n > 10 } }, "a = 15; c > 10"], * () => "other", * ]); + * } * - * assert.equal(matchObj({ a: 5, b: { c: 5 } }), "a is 5"); - * assert.equal(matchObj({ a: 50, b: { c: 5 } }), "c is 5"); - * assert.equal(matchObj({ a: 10, b: { c: 20 } }), "a 10 c gt 10"); - * assert.equal(matchObj({ a: 8, b: { c: 8 } }), "other"); + * assert.equal(matchObj({ a: 5 }), "a = 5"); + * assert.equal(matchObj({ a: 50, b: { c: 5 } }), "c = 5"); + * assert.equal(matchObj({ a: 10 }), "other"); + * assert.equal(matchObj({ a: 10, o: 1 }), "a = 10, o = _"); + * assert.equal(matchObj({ a: 15, b: { c: 20 } }), "a = 15; c > 10"); + * assert.equal(matchObj({ a: 8, b: { c: 8 }, o: 1 }), "other"); + * ``` + * + * #### Array + * + * The branch succeeds if all the indexes in `condition` match those in the + * provided value. Using `_` allows any value (even undefined), but the index + * must still be present. * - * // Arrays - * const matchArr = (arr: number[]) => - * match(arr, [ + * ``` + * function matchArr(arr: number[]): string { + * return match(arr, [ * [[1], "1"], - * [[2, (n) => n > 10], "2 gt10"], - * [[_, 6, _, 12], "_ 6 _ 12"], + * [[2, (x) => x > 10], "2, > 10"], + * [[_, 6, 9, _], (a) => a.join(", ")], * () => "other", * ]); + * } * * assert.equal(matchArr([1, 2, 3]), "1"); - * assert.equal(matchArr([2, 12, 6]), "2 gt10"); - * assert.equal(matchArr([3, 6, 9, 12]), "_ 6 _ 12"); + * assert.equal(matchArr([2, 12, 6]), "2, > 10"); + * assert.equal(matchArr([3, 6, 9]), "other"); + * assert.equal(matchArr([3, 6, 9, 12]), "3, 6, 9, 12"); * assert.equal(matchArr([2, 4, 6]), "other"); * ``` * - * ### Combined Matching - * It's possible to combine the mapped and chained approach, to create a chain - * of rules for the unwrapped mapped type. Here are three ways of doing the - * same thing: + * #### Some, Ok and Err + * + * The branch succeeds if the wrapping monad (e.g. `Some`) is the same as the + * provided value and the inner `condition` matches the inner value. + * + * **Note:** Filter functions are not called for any condition wrapped in a + * monad. See the section on Combined Matching for a way to match inner values. * * ``` - * interface Player { - * name: string; - * age: number; - * status: string; - * } + * type NumberMonad = Option | Result; * - * Shortest - * function can_proceed_1(player: Option): boolean { - * return match(player, { - * Some: (pl) => pl.age >= 18 && pl.status !== "banned", - * None: () => false, - * }); + * function matchMonad(val: NumberMonad): string { + * return match(val, [ + * [Some(1), "Some"], + * [Ok(1), "Ok"], + * [Err(1), "Err"], + * () => "None", + * ]); * } * - * // Easiest to read and add to - * function can_proceed_2(player: Option): boolean { - * return match(player, { - * Some: [ - * [{ status: "banned" }, false], - * [{ age: (n) => n > 18 }, true], - * ], - * _: () => false, - * }); - * } + * assert.equal(matchMonad(Some(1)), "Some"); + * assert.equal(matchMonad(Ok(1)), "Ok"); + * assert.equal(matchMonad(Err(1)), "Err"); + * assert.equal(matchMonad(None), "None"); + * ``` * - * // Bad. SomeIs and similar methods may be changed. - * function can_proceed_3(player: Option): boolean { - * return match(player, [ - * [Some({ status: "banned" }), false], - * [SomeIs((pl) => pl.age >= 18), true], - * () => false, + * #### Fn (function as value) + * + * This wrapper distinguishes between a function to be called and a function to + * be treated as a value. It is needed where the function value could be confused + * with a filter function or result function. + * + * ``` + * const fnOne = () => 1; + * const fnTwo = () => 2; + * const fnDefault = () => "fnDefault"; + * + * function matchFn(fnVal: (...args: any) => any): () => string { + * return match(fnVal, [ + * [Fn(fnOne), () => () => "fnOne"], // Manual result wrapper + * [Fn(fnTwo), Fn(() => "fnTwo")], // Fn result wrapper + * () => fnDefault, * ]); * } + * + * assert.equal(matchFn(fnOne)(), "fnOne"); + * assert.equal(matchFn(fnTwo)(), "fnTwo"); + * assert.equal(matchFn(() => 0)(), "fnDefault"); * ``` */ -function match(pat: MappedOption): (opt: Option) => U; -function match(pat: MappedResult): (res: Result) => U; -function match(opt: Option, pat: MappedOption): U; -function match(res: Result, pat: MappedResult): U; -function match(opt: Option, pat: OptionBranches): U; -function match(opt: Result, pat: ResultBranches): U; -function match(val: T, pat: Branches): U; -function match( - val: - | T - | MappedOption - | MappedResult - | Option - | Result, - pat?: - | Branches - | MappedOption - | MappedResult - | OptionBranches - | ResultBranches -): U | ((opt: Option) => U) | ((res: Result) => U) { - if (is_object_like(pat)) { - if (Array.isArray(pat)) { - return match_branches(val as any, pat as any); - } +export function match( + val: T, + pattern: MappedBranches | ChainedBranches +): U { + return matchDispatch(val, pattern, Default); +} - if (Option.is(val) && is_object_like(pat)) { - const { Some, None, _ } = pat as MappedOption; - return val.isSome() - ? call_or_branch(val.unwrapUnchecked() as T, Some, _) - : call_or_branch(Default, None, _); - } +match.compile = compile; - if (Result.is(val) && is_object_like(pat)) { - const { Ok, Err, _ } = pat as MappedResult; - return val.isOk() - ? call_or_branch(val.unwrapUnchecked() as T, Ok, _) - : call_or_branch(val.unwrapUnchecked() as E, Err, _); - } - } +export type match = typeof match; - if (pat === undefined && is_object_like(val)) { - const mapped = { _: () => BubbleToDefault, ...val }; - return (val: Option | Result) => - match(val, mapped as any) as any; - } +/** + * Compile a `match` pattern to a new function. This can improve performance + * by re-using the same pattern object on every invocation. + * + * #### Mapped Match + * + * ``` + * const matchSome = match.compile({ + * Some: (n: number) => `got some ${n}`, + * None: () => "got none", + * }); + * + * assert.equal(matchSome(Some(1)), "got some 1"); + * assert.equal(matchSome(None), "got none"); + * ``` + * + * #### Chained Match + * + * ``` + * const matchNum = match.compile([ + * [1, "got 1"], + * [2, "got 2"], + * [(n) => n > 100, "got > 100"], + * () => "default", + * ]); + * + * assert.equal(matchNum(1), "got 1"); + * assert.equal(matchNum(2), "got 2"); + * assert.equal(matchNum(5), "default"); + * assert.equal(matchNum(150), "got > 100"); + * ``` + * + * #### Advanced Types + * + * The compiler can't always infer the correct input type from the pattern. In + * these cases we need to provide them: + * + * ``` + * type ResOpt = Result, number>; + * const matchResOpt = match.compile({ + * Ok: { Some: (s) => `some ${s}` }, + * _: () => "default", + * }); + * + * assert.equal(matchResOpt(Ok(Some("test"))), "some test"); + * assert.equal(matchResOpt(Ok(None)), "default"); + * assert.equal(matchResOpt(Err(1)), "default"); + * ``` + */ +function compile( + pattern: MappedBranches | ChainedBranches +): (val: T) => U; +function compile( + pattern: MappedBranches, U> +): (val: Option) => U; +function compile( + pattern: MappedBranches, U> +): (val: Result) => U; +function compile( + pattern: MappedBranches | ChainedBranches +): (val: T) => U { + return (val) => match(val, pattern); +} + +/** + * The `Default` (or `_`) value. Used as a marker to indicate "any value". + */ +export const Default: any = () => { + throw new Error("Match failed (exhausted)"); +}; +export type Default = any; + +/** + * The `_` value. Used as a marker to indicate "any value". + */ +export const _ = Default; +export type _ = any; - throw new Error("Match failed, unknown call signature"); +/** + * Creates a wrapper for a function so that it will be treated as a value + * within a chained matching block. See `match` for more information about + * when this needs to be used. + */ +export function Fn any>(fn: T): () => T { + const val: any = () => throwFnCalled(); + (val as any)[FnVal] = fn; + return val; } -function call_or_branch( +export type Fn = { (): never; [FnVal]: T }; + +function matchMapped( val: T, - branch?: Mapped | Branches, - default_branch?: Mapped + pattern: OptionMapped & ResultMapped, + defaultBranch: DefaultBranch ): U { - if (typeof branch === "function") { - const result = branch(val); - return (result as any) === BubbleToDefault - ? match_branches(val, undefined, default_branch) - : result; + if (Option.is(val)) { + if (val[T]) { + if (pattern.Some) { + if (typeof pattern.Some === "function") { + return pattern.Some(val[Val]); + } else { + return matchDispatch( + val[Val], + pattern.Some, + typeof pattern._ === "function" ? pattern._ : defaultBranch + ); + } + } + } else if (typeof pattern.None === "function") { + return pattern.None(); + } + } else if (Result.is(val)) { + const Branch = val[T] ? pattern.Ok : pattern.Err; + if (Branch) { + if (typeof Branch === "function") { + return Branch(val[Val]); + } else { + return matchDispatch( + val[Val], + Branch, + typeof pattern._ === "function" ? pattern._ : defaultBranch + ); + } + } } else { - return match_branches(val, branch, default_branch); + throwInvalidPattern(); } + + return typeof pattern._ === "function" ? pattern._() : defaultBranch(); } -function match_branches( +function matchChained( val: T, - branches?: Branches, - default_branch?: Mapped + pattern: ChainedBranches, + defaultBranch: DefaultBranch ): U { - if (branches) { - for (const branch of branches) { - if (typeof branch === "function") { - return branch(val); - } else { - const [cond, res] = branch; - if (matches(cond, val)) { - return typeof res === "function" - ? (res as (val: T | Default) => U)(val) - : res; + for (const branch of pattern) { + if (typeof branch === "function") { + return (branch as Fn)[FnVal] ? (branch as Fn)[FnVal] : branch(); + } else { + const [cond, result] = branch; + if (matches(cond, val, true)) { + if (typeof result === "function") { + return (result as Fn)[FnVal] + ? (result as Fn)[FnVal] + : (result as (val: T) => U)(val); + } else { + return result; } } } } - if (typeof default_branch === "function") { - return default_branch(val); - } - - return Default() as never; -} - -function is_object_like(value: unknown): value is Record { - return value !== null && typeof value === "object"; + return defaultBranch(); } -function matches(cond: BranchCondition | Default, val: T): boolean { +function matches( + cond: BranchCondition, + val: T, + evaluate: boolean +): boolean { if (cond === Default || cond === val) { return true; } if (typeof cond === "function") { - return is_fn_value(cond as () => any) - ? (cond as () => any)() === val - : (cond as (val: T | Default) => boolean)(val); + return (cond as Fn)[FnVal] + ? (cond as Fn)[FnVal] === val + : evaluate && (cond as (val: T) => boolean)(val); } - if (Option.is(cond) || Result.is(cond)) { - return ( - cond.is(val) && matches(cond.unwrapUnchecked(), val.unwrapUnchecked()) - ); - } + if (isObjectLike(cond)) { + if (T in cond) { + return ( + (cond as any).isLike(val) && + matches((cond as any)[Val], (val as any)[Val], false) + ); + } + + if (isObjectLike(val) && Array.isArray(cond) === Array.isArray(val)) { + for (const key of Object.keys(cond)) { + if ( + !(key in val) || + !matches((cond as any)[key], (val as any)[key], evaluate) + ) { + return false; + } + } - if ( - is_object_like(cond) && - is_object_like(val) && - Array.isArray(cond) === Array.isArray(val) - ) { - return match_deep(cond, val); + return true; + } } return false; } -function match_deep( - cond: Record, - val: Record -): boolean { - for (const key of Object.keys(cond)) { - if (cond[key] !== Default && !matches(cond[key], val[key])) { - return false; - } +function matchDispatch( + val: T, + pattern: ChainedBranches | MappedBranches, + defaultBranch: DefaultBranch +): U { + if (Array.isArray(pattern)) { + return matchChained(val, pattern, defaultBranch); + } else if (isObjectLike(pattern)) { + return matchMapped(val, pattern, defaultBranch); } - return true; -} -function is_fn_value(fn: (...args: any) => any): boolean { - return (fn as any).__IsFnValue__ === true; + throwInvalidPattern(); } -export { match }; - -/** - * Creates a wrapper for a function-value within a chained match block. See - * `match` for more information about when this needs to be used. - */ -export function Fn any>(fn: T): () => T { - const output = () => fn; - output.__IsFnValue__ = true; - return output; +function isObjectLike(value: unknown): value is Record { + return value !== null && typeof value === "object"; } -/** - * Creates a new function that accepts an `Option` and returns `fn(T)` or - * `false` if the Option is `None`. This implementation kind of sucks, I'll - * probably remove it. - * @deprecated - */ -export function SomeIs(fn: (val: T) => boolean): Mapped, boolean> { - return (opt: Option) => opt.isSome() && fn(opt.unwrapUnchecked() as T); +function throwInvalidPattern(): never { + throw new Error("Match failed (invalid pattern)"); } -/** - * Creates a new function that accepts a `Result` and returns `fn(T)` - * or `false` if the Result is `Err`. Typically used in a `match` block. - * This implementation kind of sucks, I'll probably remove it. - * @deprecated - */ -export function OkIs( - fn: (val: T) => boolean -): Mapped, boolean> { - return (res: Result) => res.isOk() && fn(res.unwrapUnchecked() as T); +function throwFnCalled(): never { + throw new Error("Match error (wrapped function called)"); } - -/** - * Creates a new function that accepts a `Result` and returns `fn(E)` - * or `false` if the Result is `Ok`. This implementation kind of sucks, I'll - * probably remove it. - * @deprecated - */ -export function ErrIs( - fn: (val: E) => boolean -): Mapped, boolean> { - return (res: Result) => res.isErr() && fn(res.unwrapUnchecked() as E); -} - -const BubbleToDefault = Symbol("BubbleToDefault"); - -/** - * The `Default` (or `_`) value. This function is used as a marker to indicate - * "any value", and is also the function called when all patterns are - * exhausted. - */ -export const Default: any = Object.freeze(() => { - throw new Error("Match failed, patterns exhausted and no default present"); -}); - -/** - * The `_` value. This function is used as a marker to indicate "any value". - * It is an alias of `Default`. - */ -export const _ = Default; -export type Default = any; -export type _ = any; - -export type Fn = typeof Fn; -export type SomeIs = typeof SomeIs; -export type OkIs = typeof OkIs; -export type ErrIs = typeof ErrIs; diff --git a/src/monad/option.ts b/src/monad/option.ts deleted file mode 100644 index 4172f06..0000000 --- a/src/monad/option.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { Result, Ok, Err } from "./result"; - -const IsSome = Symbol("IsSome"); - -export type Option = Some | None; -export type Some = OptionType & { [IsSome]: true }; -export type None = OptionType & { [IsSome]: false }; - -class OptionType { - private val: T; - readonly [IsSome]: boolean; - - constructor(val: T, some: boolean) { - this.val = val; - this[IsSome] = some; - Object.freeze(this); - } - - /** - * See `like()`. - * @deprecated - */ - is(cmp: unknown): cmp is Option { - return this.isLike(cmp); - } - - /** - * Compares the Option to `cmp`, returns true if both are `Some` or both - * are `None`. Also acts as a type guard for `Option`. - * - * ``` - * const s: Option = Some(1); - * const n: Option = None; - * - * assert.equal(s.isLike(Some(10)), true); - * assert.equal(n.isLike(None), true); - * assert.equal(s.isLike(n), false); - * ``` - */ - isLike(cmp: unknown): cmp is Option { - return cmp instanceof OptionType && this[IsSome] === cmp[IsSome]; - } - - /** - * See `equals()`. - * @deprecated - */ - eq(cmp: Option): boolean { - return this.equals(cmp); - } - - /** - * Compares the Option to `cmp` for equality. Returns `true` when both are - * `Some` with identical contained values, or both are `None`. - * - * ``` - * const val = { x: 10 }; - * const s: Option<{ x: number; }> = Some(val); - * const n: Option<{ x: number; }> = None; - * - * assert.equal(s.equals(Some(val)), true); - * assert.equal(n.equals(None), true): - * assert.equal(s.equals(Some({ x: 10 })), false); - * assert.equal(s.equals(n), false); - * ``` - */ - equals(cmp: Option): boolean { - return this[IsSome] === cmp[IsSome] && this.val === cmp.val; - } - - /** - * Compares the Option to `cmp` for inequality. Returns true when both are - * different types (`Some`/`None`) or their contained values are not - * identical (`!==`). - * - * const val = { x: 10 }; - * const s: Option<{ x: number; }> = Some(val); - * const n: Option<{ x: number; }> = None; - * - * assert.equal(s.neq(Some(val)), false); - * assert.equal(n.neq(None), false); - * assert.equal(s.neq(Some({ x: 10})), true); - * assert.equal(s.new(n), true); - * ``` - * - * @deprecated - */ - neq(cmp: Option): boolean { - return this[IsSome] !== cmp[IsSome] || this.val !== cmp.val; - } - - /** - * Returns true if the Option is `Some`. Acts as a type guard for - * `this is Some`. - * - * ``` - * const x = Some(10); - * assert.equal(x.isSome(), true); - * - * const x: Option = None; - * assert.equal(x.isSome(), false); - * ``` - */ - isSome(): this is Some { - return this[IsSome]; - } - - /** - * Returns true if the Option is `None`. Acts as a type guard for - * `this is None`. - * - * ``` - * const x = Some(10); - * assert.equal(x.isNone(), false); - * - * const x: Option = None; - * assert.equal(x.isNone(), true); - * ``` - */ - isNone(): this is None { - return !this[IsSome]; - } - - /** - Returns the contained `Some` value and throws `Error(msg)` if `None`. - - To avoid throwing, consider `isSome`, `unwrapOr`, `unwrapOrElse` or - `match` to handle the `None` case. - - ``` - const x = Some(1); - assert.equal(x.expect("Is empty"), 1); - - const x: Option = None; - const y = x.expect("Is empty"); // throws - * ``` - */ - expect(msg: string): T { - if (this[IsSome]) { - return this.val; - } else { - throw new Error(msg); - } - } - - /** - Returns the contained `Some` value and throws if `None`. - - To avoid throwing, consider `isSome`, `unwrapOr`, `unwrapOrElse` or - `match` to handle the `None` case. To throw a more informative error use - `expect`. - - ``` - const x = Some(1); - assert.equal(x.unwrap(), 1); - - const x: Option = None; - const y = x.unwrap(); // throws - * ``` - */ - unwrap(): T { - return this.expect("Failed to unwrap Option (found None)"); - } - - /** - * Returns the contained `Some` value or a provided default. - * - * The provided default is eagerly evaluated. If you are passing the result - * of a function call, consider `unwrapOrElse`, which is lazily evaluated. - * - * ``` - * const x = Some(10); - * assert.equal(x.unwrapOr(1), 10); - * - * const x: Option = None; - * assert.equal(x.unwrapOr(1), 1); - * ``` - */ - unwrapOr(def: T): T { - return this[IsSome] ? this.val : def; - } - - /** - * Returns the contained `Some` value or computes it from a function. - * - * ``` - * const x = Some(10); - * assert.equal(x.unwrapOrElse(() => 1 + 1), 10); - * - * const x: Option = None; - * assert.equal(x.unwrapOrElse(() => 1 + 1), 2); - * ``` - */ - unwrapOrElse(f: () => T): T { - return this[IsSome] ? this.val : f(); - } - - /** - * Returns the contained `Some` value or undefined if `None`. - * - * Most problems are better solved using one of the other `unwrap_` methods. - * This method should only be used when you are certain that you need it. - * - * ``` - * const x = Some(10); - * assert.equal(x.unwrapUnchecked(), 10); - * - * const x: Option = None; - * assert.equal(x.unwrapUnchecked(), undefined); - * ``` - */ - unwrapUnchecked(): T | undefined { - return this.val; - } - - /** - * Returns the Option if it is `Some`, otherwise returns `optb`. - * - * `optb` is eagerly evaluated. If you are passing the result of a function - * call, consider `orElse`, which is lazily evaluated. - * - * ``` - * const x = Some(10); - * const xor = x.or(Some(1)); - * assert.equal(xor.unwrap(), 10); - * - * const x: Option = None; - * const xor = x.or(Some(1)); - * assert.equal(xor.unwrap(), 1); - * ``` - */ - or(optb: Option): Option { - return this[IsSome] ? this : optb; - } - - /** - * Returns the Option if it is `Some`, otherwise returns the value of `f()`. - * - * ``` - * const x = Some(10); - * const xor = x.orElse(() => Some(1)); - * assert.equal(xor.unwrap(), 10); - * - * const x: Option = None; - * const xor = x.orElse(() => Some(1)); - * assert.equal(xor.unwrap(), 1); - * ``` - */ - orElse(f: () => Option): Option { - return this[IsSome] ? this : f(); - } - - /** - * Returns `None` if the Option is `None`, otherwise returns `optb`. - * - * ``` - * const x = Some(10); - * const xand = x.and(Some(1)); - * assert.equal(xand.unwrap(), 1); - * - * const x: Option = None; - * const xand = x.and(Some(1)); - * assert.equal(xand.isNone(), true); - * - * const x = Some(10); - * const xand = x.and(None); - * assert.equal(xand.isNone(), true); - * ``` - */ - and(optb: Option): Option { - return this[IsSome] ? optb : None; - } - - /** - * Returns `None` if the option is `None`, otherwise calls `f` with the - * `Some` value and returns the result. - * - * ``` - * const x = Some(10); - * const xand = x.andThen((n) => n + 1); - * assert.equal(xand.unwrap(), 11); - * - * const x: Option = None; - * const xand = x.andThen((n) => n + 1); - * assert.equal(xand.isNone(), true); - * - * const x = Some(10); - * const xand = x.andThen(() => None); - * assert.equal(xand.isNone(), true); - * ``` - */ - andThen(f: (val: T) => Option): Option { - return this[IsSome] ? f(this.val) : None; - } - - /** - * Maps an `Option` to `Option` by applying a function to the `Some` - * value. - * - * ``` - * const x = Some(10); - * const xmap = x.map((n) => `number ${n}`); - * assert.equal(xmap.unwrap(), "number 10"); - * ``` - */ - map(f: (val: T) => U): Option { - return this[IsSome] ? new OptionType(f(this.val), true) : None; - } - - /** - * Returns the provided default if `None`, otherwise calls `f` with the - * `Some` value and returns the result. - * - * The provided default is eagerly evaluated. If you are passing the result - * of a function call, consider `mapOrElse`, which is lazily evaluated. - * - * ``` - * const x = Some(10); - * const xmap = x.mapOr(1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 11); - * - * const x: Option = None; - * const xmap = x.mapOr(1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 1); - * ``` - */ - mapOr(def: U, f: (val: T) => U): U { - return this[IsSome] ? f(this.val) : def; - } - - /** - * Computes a default return value if `None`, otherwise calls `f` with the - * `Some` value and returns the result. - * - * const x = Some(10); - * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 11); - * - * const x: Option = None; - * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 2); - * ``` - */ - mapOrElse(def: () => U, f: (val: T) => U): U { - return this[IsSome] ? f(this.val) : def(); - } - - /** - * Transforms the `Option` into a `Result`, mapping `Some(v)` to - * `Ok(v)` and `None` to `Err(err)`. - * - * ``` - * const x = Some(10); - * const res = x.okOr("Is empty"); - * assert.equal(x.isOk(), true); - * assert.equal(x.unwrap(), 10); - * - * const x: Option = None; - * const res = x.okOr("Is empty"); - * assert.equal(x.isErr(), true); - * assert.equal(x.unwrap_err(), "Is empty"); - * ``` - */ - okOr(err: E): Result { - return this[IsSome] ? Ok(this.val) : Err(err); - } - - /** - * Transforms the `Option` into a `Result`, mapping `Some(v)` to - * `Ok(v)` and `None` to `Err(f())`. - * - * ``` - * const x = Some(10); - * const res = x.okOrElse(() => ["Is", "empty"].join(" ")); - * assert.equal(x.isOk(), true); - * assert.equal(x.unwrap(), 10); - * - * const x: Option = None; - * const res = x.okOrElse(() => ["Is", "empty"].join(" ")); - * assert.equal(x.isErr(), true); - * assert.equal(x.unwrap_err(), "Is empty"); - * ``` - */ - okOrElse(f: () => E): Result { - return this[IsSome] ? Ok(this.val) : Err(f()); - } -} - -/** - * Tests the provided `val` is an Option. Acts as a type guard for - * `val is Option`. - * - * ``` - * assert.equal(Option.is(Some(1), true); - * assert.equal(Option.is(None, true)); - * assert.equal(Option.is(Ok(1), false)); - * ``` - */ -export function isOption(val: unknown): val is Option { - return val instanceof OptionType; -} - -/** - * Creates a `Some` value, which can be used where an `Option` is - * required. See Option for more examples. - * - * ``` - * const x = Some(10); - * assert.equal(x.isSome(), true); - * assert.equal(x.unwrap(), 10); - * ``` - */ -export function Some(val: T): Some { - return new OptionType(val, true) as Some; -} - -/** - * The `None` value, which can be used where an `Option` is required. - * See Option for more examples. - * - * ``` - * const x = None; - * assert.equal(x.isNone(), true); - * const y = x.unwrap(); // throws - * ``` - */ -export const None = new OptionType(undefined, false) as None; - -interface OptionType { - /** @deprecated */ - is_some: OptionType["isSome"]; - /** @deprecated */ - is_none: OptionType["isNone"]; - /** @deprecated */ - unwrap_or: OptionType["unwrapOr"]; - /** @deprecated */ - unwrap_or_else: OptionType["unwrapOrElse"]; - /** @deprecated */ - unwrap_unchecked: OptionType["unwrapUnchecked"]; - /** @deprecated */ - or_else: OptionType["orElse"]; - /** @deprecated */ - and_then: OptionType["andThen"]; - /** @deprecated */ - map_or: OptionType["mapOr"]; - /** @deprecated */ - map_or_else: OptionType["mapOrElse"]; - /** @deprecated */ - ok_or: OptionType["okOr"]; - /** @deprecated */ - ok_or_else: OptionType["okOrElse"]; -} - -Object.assign(OptionType.prototype, { - is_some: OptionType.prototype.isSome, - is_none: OptionType.prototype.isNone, - unwrap_or: OptionType.prototype.unwrapOr, - unwrap_or_else: OptionType.prototype.unwrapOrElse, - unwrap_unchecked: OptionType.prototype.unwrapUnchecked, - or_else: OptionType.prototype.orElse, - and_then: OptionType.prototype.andThen, - map_or: OptionType.prototype.mapOr, - map_or_else: OptionType.prototype.mapOrElse, - ok_or: OptionType.prototype.okOr, - ok_or_else: OptionType.prototype.okOrElse, -}); - -Object.freeze(OptionType.prototype); -Object.freeze(isOption); -Object.freeze(Some); -Object.freeze(None); diff --git a/src/monad/result.ts b/src/monad/result.ts deleted file mode 100644 index 9a3475a..0000000 --- a/src/monad/result.ts +++ /dev/null @@ -1,511 +0,0 @@ -import { Option, Some, None } from "./option"; - -const IsOk = Symbol("IsOk"); - -export type Result = Ok | Err; -export type Ok = ResultType & { [IsOk]: true }; -export type Err = ResultType & { [IsOk]: false }; - -class ResultType { - private val: T | E; - readonly [IsOk]: boolean; - - constructor(val: T | E, ok: boolean) { - this.val = val; - this[IsOk] = ok; - Object.freeze(this); - } - - /** - * See `isLike()`. - * @deprecated - */ - is(cmp: unknown): cmp is Result { - return this.isLike(cmp); - } - - /** - * Compares the Result to `cmp`, returns true if both are `Ok` or both - * are `Err`. Acts as a type guard for `cmp is Result`. - * - * ``` - * const o = Ok(1); - * const e = Err(1); - * - * assert.equal(o.isLike(Ok(1))), true); - * assert.equal(e.isLike(Err(1)), true); - * assert.equal(o.isLike(e), false); - * ``` - */ - isLike(cmp: unknown): cmp is Result { - return cmp instanceof ResultType && this[IsOk] === cmp[IsOk]; - } - - /** - * See `equals()`. - * @deprecated - */ - eq(cmp: Result): boolean { - return this.equals(cmp); - } - - /** - * Compares the Result to `cmp` for equality. Returns `true` when both are - * the same type (`Ok`/`Err`) and their contained values are identical - * (`===`). - * - * ``` - * const val = { x: 10 }; - * const o: Result<{ x: number; }, { x: number; }> = Ok(val); - * const e: Result<{ x: number; }, { x: number; }> = Err(val); - * - * assert.equal(o.equals(Ok(val)), true); - * assert.equal(e.equals(Err(val)), true): - * assert.equal(o.equals(Ok({ x: 10 })), false); - * assert.equal(e.equals(Err({ x: 10 })), false); - * assert.equal(o.equals(e), false); - * ``` - */ - equals(cmp: Result): boolean { - return this[IsOk] === cmp[IsOk] && this.val === cmp.val; - } - - /** - * Compares the Result to `cmp` for inequality. Returns true when both are - * different types (`Ok`/`Err`) or their contained values are not identical - * (`!==`). - * - * ``` - * const val = { x: 10 }; - * const o: Result<{ x: number; }, { x: number; }> = Ok(val); - * const e: Result<{ x: number; }, { x: number; }> = Err(val); - * - * assert.equal(o.neq(Ok(val)), false); - * assert.equal(e.neq(Err(val)), false): - * assert.equal(o.neq(Ok({ x: 10 })), true); - * assert.equal(e.neq(Err({ x: 10 })), true); - * assert.equal(o.neq(e), true); - * ``` - * - * @deprecated - */ - neq(cmp: Result): boolean { - return this[IsOk] !== cmp[IsOk] || this.val !== cmp.val; - } - - /** - * Returns true if the Result is `Ok`. Acts as a type guard for - * `this is Ok`. - * - * @example - * const x = Ok(10); - * assert.equal(x.isOk(), true); - * - * const x = Err(10); - * assert.equal(x.isOk(), false); - */ - isOk(): this is Ok { - return this[IsOk]; - } - - /** - * Returns true if the Result is `Err`. Acts as a type guard for - * `this is Err`. - * - * @example - * const x = Ok(10); - * assert.equal(x.isErr(), false); - * - * const x = Err(10); - * assert.equal(x.isErr(), true); - */ - isErr(): this is Err { - return !this[IsOk]; - } - - /** - Returns the contained `Ok` value and throws `Error(msg)` if `Err`. - - To avoid throwing, consider `isOk`, `unwrapOr`, `unwrapOrElse` or - `match` to handle the `Err` case. - - @example - const x = Ok(1); - assert.equal(x.expect("Was Err"), 1); - - const x = Err(1); - const y = x.expect("Was Err"); // throws - */ - expect(msg: string): T { - if (this[IsOk]) { - return this.val as T; - } else { - throw new Error(msg); - } - } - - /** - Returns the contained `Err` value and throws `Error(msg)` if `Ok`. - - To avoid throwing, consider `isErr` or `match` to handle the `Ok` case. - - @example - const x = Ok(1); - const y = x.expectErr("Was Ok"); // throws - - const x = Err(1); - assert.equal(x.expectErr("Was Ok"), 1); - */ - expectErr(msg: string): E { - if (this[IsOk]) { - throw new Error(msg); - } else { - return this.val as E; - } - } - - /** - Returns the contained `Ok` value and throws if `Err`. - - To avoid throwing, consider `isOk`, `unwrapOr`, `unwrapOrElse` or - `match` to handle the `Err` case. To throw a more informative error use - `expect`. - - @example - const x = Ok(1); - assert.equal(x.unwrap(), 1); - - const x = Err(1); - const y = x.unwrap(); // throws - */ - unwrap(): T { - return this.expect("Failed to unwrap Result (found Err)"); - } - - /** - Returns the contained `Err` value and throws if `Ok`. - - To avoid throwing, consider `isErr` or `match` to handle the `Ok` case. - To throw a more informative error use `expectErr`. - - @example - const x = Ok(1); - const y = x.unwrap(); // throws - - const x = Err(1); - assert.equal(x.unwrap(), 1); - */ - unwrapErr(): E { - return this.expectErr("Failed to unwrapErr Result (found Ok)"); - } - - /** - * Returns the contained `Ok` value or a provided default. - * - * The provided default is eagerly evaluated. If you are passing the result - * of a function call, consider `unwrapOrElse`, which is lazily evaluated. - * - * @example - * const x = Ok(10); - * assert.equal(x.unwrapOr(1), 10); - * - * const x = Err(10); - * assert.equal(x.unwrapOr(1), 1); - */ - unwrapOr(def: T): T { - return this[IsOk] ? (this.val as T) : def; - } - - /** - * Returns the contained `Ok` value or computes it from a function. - * - * @example - * const x = Ok(10); - * assert.equal(x.unwrapOrElse(() => 1 + 1), 10); - * - * const x = Err(10); - * assert.equal(x.unwrapOrElse(() => 1 + 1), 2); - */ - unwrapOrElse(f: () => T): T { - return this[IsOk] ? (this.val as T) : f(); - } - - /** - * Returns the contained `Ok` or `Err` value. - * - * Most problems are better solved using one of the other `unwrap_` methods. - * This method should only be used when you are certain that you need it. - * - * @example - * const x = Ok(10); - * assert.equal(x.unwrapUnchecked(), 10); - * - * const x = Err(20); - * assert.equal(x.unwrapUnchecked(), 20); - */ - unwrapUnchecked(): T | E { - return this.val; - } - - /** - * Returns the Option if it is `Ok`, otherwise returns `resb`. - * - * `resb` is eagerly evaluated. If you are passing the result of a function - * call, consider `orElse`, which is lazily evaluated. - * - * @example - * const x = Ok(10); - * const xor = x.or(Ok(1)); - * assert.equal(xor.unwrap(), 10); - * - * const x = Err(10); - * const xor = x.or(Ok(1)); - * assert.equal(xor.unwrap(), 1); - */ - or(resb: Result): Result { - return this[IsOk] ? this : resb; - } - - /** - * Returns the Result if it is `Ok`, otherwise returns the value of `f()` - * mapping `Result` to `Result`. - * - * @example - * const x = Ok(10); - * const xor = x.orElse(() => Ok(1)); - * assert.equal(xor.unwrap(), 10); - * - * const x = Err(10); - * const xor = x.orElse(() => Ok(1)); - * assert.equal(xor.unwrap(), 1); - * - * const x = Err(10); - * const xor = x.orElse((e) => Err(`val ${e}`)); - * assert.equal(xor.unwrapErr(), "val 10"); - */ - orElse(f: (err: E) => Result): Result { - return this[IsOk] ? (this as unknown as Result) : f(this.val as E); - } - - /** - * Returns itself if the Result is `Err`, otherwise returns `resb`. - * - * @example - * const x = Ok(10); - * const xand = x.and(Ok(1)); - * assert.equal(xand.unwrap(), 1); - * - * const x = Err(10); - * const xand = x.and(Ok(1)); - * assert.equal(xand.unwrapErr(), 10); - * - * const x = Ok(10); - * const xand = x.and(Err(1)); - * assert.equal(xand.unwrapErr(), 1); - */ - and(resb: Result): Result { - return this[IsOk] ? resb : (this as Err); - } - - /** - * Returns itself if the Result is `Err`, otherwise calls `f` with the `Ok` - * value and returns the result. - * - * @example - * const x = Ok(10); - * const xand = x.andThen((n) => n + 1); - * assert.equal(xand.unwrap(), 11); - * - * const x = Err(10); - * const xand = x.andThen((n) => n + 1); - * assert.equal(xand.unwrapErr(), 10); - * - * const x = Ok(10); - * const xand = x.and(Err(1)); - * assert.equal(xand.unwrapErr(), 1); - */ - andThen(f: (val: T) => Result): Result { - return this[IsOk] ? f(this.val as T) : (this as Err); - } - - /** - * Maps a `Result` to `Result` by applying a function to the - * `Ok` value. - * - * @example - * const x = Ok(10); - * const xmap = x.map((n) => `number ${n}`); - * assert.equal(xmap.unwrap(), "number 10"); - */ - map(f: (val: T) => U): Result { - return new ResultType( - this[IsOk] ? f(this.val as T) : (this.val as E), - this[IsOk] - ); - } - - /** - * Maps a `Result` to `Result` by applying a function to the - * `Err` value. - * - * @example - * const x = Err(10); - * const xmap = x.mapErr((n) => `number ${n}`); - * assert.equal(xmap.unwrapErr(), "number 10"); - */ - mapErr(op: (err: E) => F): Result { - return new ResultType( - this[IsOk] ? (this.val as T) : op(this.val as E), - this[IsOk] - ); - } - - /** - * Returns the provided default if `Err`, otherwise calls `f` with the - * `Ok` value and returns the result. - * - * The provided default is eagerly evaluated. If you are passing the result - * of a function call, consider `mapOrElse`, which is lazily evaluated. - * - * @example - * const x = Ok(10); - * const xmap = x.mapOr(1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 11); - * - * const x = Err(10); - * const xmap = x.mapOr(1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 1); - */ - mapOr(def: U, f: (val: T) => U): U { - return this[IsOk] ? f(this.val as T) : def; - } - - /** - * Computes a default return value if `Err`, otherwise calls `f` with the - * `Ok` value and returns the result. - * - * const x = Ok(10); - * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 11); - * - * const x = Err(10); - * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); - * assert.equal(xmap.unwrap(), 2); - */ - mapOrElse(def: (err: E) => U, f: (val: T) => U): U { - return this[IsOk] ? f(this.val as T) : def(this.val as E); - } - - /** - * Transforms the `Result` into an `Option`, mapping `Ok(v)` to - * `Some(v)`, discarding any `Err` value and mapping to None. - * - * @example - * const x = Ok(10); - * const opt = x.ok(); - * assert.equal(x.isSome(), true); - * assert.equal(x.unwrap(), 10); - * - * const x = Err(10); - * const opt = x.ok(); - * assert.equal(x.isNone(), true); - * const y = x.unwrap(); // throws - */ - ok(): Option { - return this[IsOk] ? Some(this.val as T) : None; - } -} - -/** - * Tests the provided `val` is an Result. Acts as a type guard for - * `val is Result`. - * - * @example - * assert.equal(Result.is(Ok(1), true); - * assert.equal(Result.is(Err(1), true)); - * assert.equal(Result.is(Some(1), false)); - */ -export function isResult(val: unknown): val is Result { - return val instanceof ResultType; -} - -/** - * Creates an `Ok` value, which can be used where a `Result` is - * required. See Result for more examples. - * - * Note that the counterpart `Err` type `E` is set to the same type as `T` - * by default. TypeScript will usually infer the correct `E` type from the - * context (e.g. a function which accepts or returns a Result). - * - * @example - * const x = Ok(10); - * assert.equal(x.isSome(), true); - * assert.equal(x.unwrap(), 10); - */ -export function Ok(val: T): Ok { - return new ResultType(val, true) as Ok; -} - -/** - * Creates an `Err` value, which can be used where a `Result` is - * required. See Result for more examples. - * - * Note that the counterpart `Ok` type `T` is set to the same type as `E` - * by default. TypeScript will usually infer the correct `T` type from the - * context (e.g. a function which accepts or returns a Result). - * - * @example - * const x = Err(10); - * assert.equal(x.isErr(), true); - * assert.equal(x.unwrapErr(), 10); - */ -export function Err(val: E): Err { - return new ResultType(val, false) as Err; -} - -interface ResultType { - /** @deprecated */ - is_ok: ResultType["isOk"]; - /** @deprecated */ - is_err: ResultType["isErr"]; - /** @deprecated */ - expect_err: ResultType["expectErr"]; - /** @deprecated */ - unwrap_err: ResultType["unwrapErr"]; - /** @deprecated */ - unwrap_or: ResultType["unwrapOr"]; - /** @deprecated */ - unwrap_or_else: ResultType["unwrapOrElse"]; - /** @deprecated */ - unwrap_unchecked: ResultType["unwrapUnchecked"]; - /** @deprecated */ - or_else: ResultType["orElse"]; - /** @deprecated */ - and_then: ResultType["andThen"]; - /** @deprecated */ - map_err: ResultType["mapErr"]; - /** @deprecated */ - map_or: ResultType["mapOr"]; - /** @deprecated */ - map_or_else: ResultType["mapOrElse"]; -} - -Object.assign(ResultType.prototype, { - is_ok: ResultType.prototype.isOk, - is_err: ResultType.prototype.isErr, - expect_err: ResultType.prototype.expectErr, - unwrap_err: ResultType.prototype.unwrapErr, - unwrap_or: ResultType.prototype.unwrapOr, - unwrap_or_else: ResultType.prototype.unwrapOrElse, - unwrap_unchecked: ResultType.prototype.unwrapUnchecked, - or_else: ResultType.prototype.orElse, - and_then: ResultType.prototype.andThen, - map_err: ResultType.prototype.mapErr, - map_or: ResultType.prototype.mapOr, - map_or_else: ResultType.prototype.mapOrElse, -}); - -Object.freeze(ResultType.prototype); -Object.freeze(isResult); -Object.freeze(Ok); -Object.freeze(Err); diff --git a/src/option.ts b/src/option.ts index d400a17..235ec61 100644 --- a/src/option.ts +++ b/src/option.ts @@ -1,96 +1,510 @@ -import { Option as BaseOption, isOption, Some, None } from "./monad/option"; -export { Some, None } from "./monad/option"; +import { T, Val, EmptyArray, IterType, FalseyValues, isTruthy } from "./common"; +import { Result, Ok, Err } from "./result"; -export type Option = BaseOption; +export type Some = OptionType & { [T]: true }; +export type None = OptionType & { [T]: false }; +export type Option = OptionType; -export interface OptionGuard { - (opt: Option): U; - bubble(opt: unknown): void; -} +type From = Exclude; type OptionTypes = { [K in keyof O]: O[K] extends Option ? T : never; }; +class OptionType { + readonly [T]: boolean; + readonly [Val]: T; + + constructor(val: T, some: boolean) { + this[T] = some; + this[Val] = val; + } + + [Symbol.iterator](this: Option): IterType { + return this[T] + ? (this[Val] as any)[Symbol.iterator]() + : EmptyArray[Symbol.iterator](); + } + + /** + * Return the contained `T`, or `none` if the option is `None`. The `none` + * value must be falsey and defaults to `undefined`. + * + * ``` + * const x: Option = Some(1); + * assert.equal(x.into(), 1); + * + * const x: Option = None; + * assert.equal(x.into(), undefined); + * + * const x: Option = None; + * assert.equal(x.into(null), null); + * ``` + */ + into(this: Option): T | undefined; + into(this: Option, none: U): T | U; + into(this: Option, none?: FalseyValues): T | FalseyValues { + return this[T] ? this[Val] : none; + } + + /** + * Compares the Option to `cmp`, returns true if both are `Some` or both + * are `None` and acts as a type guard. + * + * ``` + * const s: Option = Some(1); + * const n: Option = None; + * + * assert.equal(s.isLike(Some(10)), true); + * assert.equal(n.isLike(None), true); + * assert.equal(s.isLike(n), false); + * ``` + */ + isLike(this: Option, cmp: unknown): cmp is Option { + return cmp instanceof OptionType && this[T] === cmp[T]; + } + + /** + * Returns true if the Option is `Some` and acts as a type guard. + * + * ``` + * const x = Some(10); + * assert.equal(x.Is(), true); + * + * const x: Option = None; + * assert.equal(x.Is(), false); + * ``` + */ + isSome(this: Option): this is Some { + return this[T]; + } + + /** + * Returns true if the Option is `None` and acts as a type guard. + * + * ``` + * const x = Some(10); + * assert.equal(x.isNone(), false); + * + * const x: Option = None; + * assert.equal(x.isNone(), true); + * ``` + */ + isNone(this: Option): this is None { + return !this[T]; + } + + /** + * Calls `f` with the contained `Some` value, converting `Some` to `None` if + * the filter returns false. + * + * For more advanced filtering, consider `match`. + * + * ``` + * const x = Some(1); + * assert.equal(x.filter((v) => v < 5).unwrap(), 1); + * + * const x = Some(10); + * assert.equal(x.filter((v) => v < 5).isNone(), true); + * + * const x: Option = None; + * assert.equal(x.filter((v) => v < 5).isNone(), true); + * ``` + */ + filter(this: Option, f: (val: T) => boolean): Option { + return this[T] && f(this[Val]) ? this : None; + } + + /** + * Returns the contained `Some` value and throws `Error(msg)` if `None`. + * + * To avoid throwing, consider `Is`, `unwrapOr`, `unwrapOrElse` or + * `match` to handle the `None` case. + * + * ``` + * const x = Some(1); + * assert.equal(x.expect("Is empty"), 1); + * + * const x: Option = None; + * const y = x.expect("Is empty"); // throws + * ``` + */ + expect(this: Option, msg: string): T { + if (this[T]) { + return this[Val]; + } else { + throw new Error(msg); + } + } + + /** + * Returns the contained `Some` value and throws if `None`. + * + * To avoid throwing, consider `isSome`, `unwrapOr`, `unwrapOrElse` or + * `match` to handle the `None` case. To throw a more informative error use + * `expect`. + * + * ``` + * const x = Some(1); + * assert.equal(x.unwrap(), 1); + * + * const x: Option = None; + * const y = x.unwrap(); // throws + * ``` + */ + unwrap(this: Option): T { + return this.expect("Failed to unwrap Option (found None)"); + } + + /** + * Returns the contained `Some` value or a provided default. + * + * The provided default is eagerly evaluated. If you are passing the result + * of a function call, consider `unwrapOrElse`, which is lazily evaluated. + * + * ``` + * const x = Some(10); + * assert.equal(x.unwrapOr(1), 10); + * + * const x: Option = None; + * assert.equal(x.unwrapOr(1), 1); + * ``` + */ + unwrapOr(this: Option, def: T): T { + return this[T] ? this[Val] : def; + } + + /** + * Returns the contained `Some` value or computes it from a function. + * + * ``` + * const x = Some(10); + * assert.equal(x.unwrapOrElse(() => 1 + 1), 10); + * + * const x: Option = None; + * assert.equal(x.unwrapOrElse(() => 1 + 1), 2); + * ``` + */ + unwrapOrElse(this: Option, f: () => T): T { + return this[T] ? this[Val] : f(); + } + + /** + * Returns the contained `Some` value or undefined if `None`. + * + * Most problems are better solved using one of the other `unwrap_` methods. + * This method should only be used when you are certain that you need it. + * + * ``` + * const x = Some(10); + * assert.equal(x.unwrapUnchecked(), 10); + * + * const x: Option = None; + * assert.equal(x.unwrapUnchecked(), undefined); + * ``` + */ + unwrapUnchecked(this: Option): T | undefined { + return this[Val]; + } + + /** + * Returns the Option if it is `Some`, otherwise returns `optb`. + * + * `optb` is eagerly evaluated. If you are passing the result of a function + * call, consider `orElse`, which is lazily evaluated. + * + * ``` + * const x = Some(10); + * const xor = x.or(Some(1)); + * assert.equal(xor.unwrap(), 10); + * + * const x: Option = None; + * const xor = x.or(Some(1)); + * assert.equal(xor.unwrap(), 1); + * ``` + */ + or(this: Option, optb: Option): Option { + return this[T] ? this : optb; + } + + /** + * Returns the Option if it is `Some`, otherwise returns the value of `f()`. + * + * ``` + * const x = Some(10); + * const xor = x.orElse(() => Some(1)); + * assert.equal(xor.unwrap(), 10); + * + * const x: Option = None; + * const xor = x.orElse(() => Some(1)); + * assert.equal(xor.unwrap(), 1); + * ``` + */ + orElse(this: Option, f: () => Option): Option { + return this[T] ? this : f(); + } + + /** + * Returns `None` if the Option is `None`, otherwise returns `optb`. + * + * ``` + * const x = Some(10); + * const xand = x.and(Some(1)); + * assert.equal(xand.unwrap(), 1); + * + * const x: Option = None; + * const xand = x.and(Some(1)); + * assert.equal(xand.isNone(), true); + * + * const x = Some(10); + * const xand = x.and(None); + * assert.equal(xand.isNone(), true); + * ``` + */ + and(this: Option, optb: Option): Option { + return this[T] ? optb : None; + } + + /** + * Returns `None` if the option is `None`, otherwise calls `f` with the + * `Some` value and returns the result. + * + * ``` + * const x = Some(10); + * const xand = x.andThen((n) => n + 1); + * assert.equal(xand.unwrap(), 11); + * + * const x: Option = None; + * const xand = x.andThen((n) => n + 1); + * assert.equal(xand.isNone(), true); + * + * const x = Some(10); + * const xand = x.andThen(() => None); + * assert.equal(xand.isNone(), true); + * ``` + */ + andThen(this: Option, f: (val: T) => Option): Option { + return this[T] ? f(this[Val]) : None; + } + + /** + * Maps an `Option` to `Option` by applying a function to the `Some` + * value. + * + * ``` + * const x = Some(10); + * const xmap = x.map((n) => `number ${n}`); + * assert.equal(xmap.unwrap(), "number 10"); + * ``` + */ + map(this: Option, f: (val: T) => U): Option { + return this[T] ? new OptionType(f(this[Val]), true) : None; + } + + /** + * Returns the provided default if `None`, otherwise calls `f` with the + * `Some` value and returns the result. + * + * The provided default is eagerly evaluated. If you are passing the result + * of a function call, consider `mapOrElse`, which is lazily evaluated. + * + * ``` + * const x = Some(10); + * const xmap = x.mapOr(1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 11); + * + * const x: Option = None; + * const xmap = x.mapOr(1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 1); + * ``` + */ + mapOr(this: Option, def: U, f: (val: T) => U): U { + return this[T] ? f(this[Val]) : def; + } + + /** + * Computes a default return value if `None`, otherwise calls `f` with the + * `Some` value and returns the result. + * + * const x = Some(10); + * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 11); + * + * const x: Option = None; + * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 2); + * ``` + */ + mapOrElse(this: Option, def: () => U, f: (val: T) => U): U { + return this[T] ? f(this[Val]) : def(); + } + + /** + * Transforms the `Option` into a `Result`, mapping `Some(v)` to + * `Ok(v)` and `None` to `Err(err)`. + * + * ``` + * const x = Some(10); + * const res = x.okOr("Is empty"); + * assert.equal(x.isOk(), true); + * assert.equal(x.unwrap(), 10); + * + * const x: Option = None; + * const res = x.okOr("Is empty"); + * assert.equal(x.isErr(), true); + * assert.equal(x.unwrap_err(), "Is empty"); + * ``` + */ + okOr(this: Option, err: E): Result { + return this[T] ? Ok(this[Val]) : Err(err); + } + + /** + * Transforms the `Option` into a `Result`, mapping `Some(v)` to + * `Ok(v)` and `None` to `Err(f())`. + * + * ``` + * const x = Some(10); + * const res = x.okOrElse(() => ["Is", "empty"].join(" ")); + * assert.equal(x.isOk(), true); + * assert.equal(x.unwrap(), 10); + * + * const x: Option = None; + * const res = x.okOrElse(() => ["Is", "empty"].join(" ")); + * assert.equal(x.isErr(), true); + * assert.equal(x.unwrap_err(), "Is empty"); + * ``` + */ + okOrElse(this: Option, f: () => E): Result { + return this[T] ? Ok(this[Val]) : Err(f()); + } +} + /** * An Option represents either something, or nothing. If we hold a value * of type `Option`, we know it is either `Some` or `None`. * + * As a function, `Option` is an alias for `Option.from`. + * * ``` - * const users = ["Simon", "Garfunkel"]; + * const users = ["Fry", "Bender"]; * function fetch_user(username: string): Option { * return users.includes(username) ? Some(username) : None; * } * * function greet(username: string): string { * return fetch_user(username) - * .map((user) => `Hello ${user}, my old friend!`) - * .unwrapOr("*silence*"); + * .map((user) => `Good news everyone, ${user} is here!`) + * .unwrapOr("Wha?"); * } * - * assert.equal(greet("Simon"), "Hello Simon, my old friend!") - * assert.equal(greet("SuperKing77"), "*silence*"); + * assert.equal(greet("Bender"), "Good news everyone, Bender is here!"); + * assert.equal(greet("SuperKing"), "Wha?"); * ``` + */ +export function Option(val: T): Option> { + return from(val); +} + +Option.is = is; +Option.from = from; +Option.nonNull = nonNull; +Option.qty = qty; +Option.safe = safe; +Option.all = all; +Option.any = any; + +/** + * Creates a `Some` value, which can be used where an `Option` is + * required. See Option for more examples. * - * ### Guarded Function Helper - * ## DEPRECATED + * ``` + * const x = Some(10); + * assert.equal(x.isSome(), true); + * assert.equal(x.unwrap(), 10); + * ``` + */ +export function Some(val: T): Some { + return new OptionType(val, true) as Some; +} + +/** + * The `None` value, which can be used where an `Option` is required. + * See Option for more examples. * - * This functionality will be removed in version 1.0.0. + * ``` + * const x = None; + * assert.equal(x.isNone(), true); + * const y = x.unwrap(); // throws + * ``` + */ +export const None = Object.freeze( + new OptionType(undefined as never, false) +); + +/** + * Tests whether the provided `val` is an Option, and acts as a type guard. * - * Calling `Option(fn)` creates a new function with an `OptionGuard` helper. - * The guard lets you quickly and safely unwrap other `Option` values, and - * causes the function to return early with `None` if an unwrap fails. A - * function created in this way always returns an `Option`. + * ``` + * assert.equal(Option.is(Some(1), true); + * assert.equal(Option.is(None, true)); + * assert.equal(Option.is(Ok(1), false)); + * ``` + */ +function is(val: unknown): val is Option { + return val instanceof OptionType; +} + +/** + * Creates a new `Option` which is `Some` unless the provided `val` is + * falsey, an instance of `Error` or an invalid `Date`. This function is + * aliased by `Option`. * - * Note: If you intend to use `try`/`catch` inside this function, see - * tests/examples/guard-bubbling.ts for some possible pit-falls. + * The `T` type is narrowed to exclude falsey orError values. * * ``` - * function to_pos(pos: number): Option { - * return pos > 0 && pos < 100 ? Some(pos * 10) : None; - * } + * assert.equal(Option.from(1).unwrap(), 1); + * assert.equal(from(0).isNone(), true); * - * // (x: number, y: number) => Option<{ x: number; y: number }>; - * const get_pos = Option((guard, x: number, y: number) => { - * return Some({ - * x: guard(to_pos(x)), - * y: guard(to_pos(y)), - * }); - * }); - * - * function show_pos(x: number, y: number): string { - * return get_pos(x, y).mapOr( - * "Invalid Pos", - * ({ x, y }) => `Pos (${x},${y})` - * ); - * } + * const err = Option.from(new Error("msg")); + * assert.equal(err.isNone(), true); + * ``` + */ +function from(val: T): Option> { + return isTruthy(val) && !(val instanceof Error) ? (Some(val) as any) : None; +} + +/** + * Creates a new `Option` which is `Some` unless the provided `val` is + * `undefined`, `null` or `NaN`. * - * assert.equal(show_pos(10, 20), "Pos (100,200)"); - * assert.equal(show_pos(1, 99), "Pos (10,990)"); - * assert.equal(show_pos(0, 50), "Invalid Pos"); - * assert.equal(show_pos(50, 100), "Invalid Pos"); + * ``` + * assert.equal(Option.nonNull(1).unwrap(), 1); + * assert.equal(Option.nonNull(0).unwrap(), 0); + * assert.equal(Option.nonNull(null).isNone(), true); * ``` */ -export function Option( - fn: (guard: OptionGuard, ...args: A) => Option -): (...args: A) => Option { - return (...args) => { - try { - return fn(guard, ...args); - } catch (err) { - if (err === OptionExit) { - return None; - } else { - throw err; - } - } - }; +function nonNull(val: T): Option> { + return val === undefined || val === null || val !== val + ? None + : Some(val as NonNullable); } -Option.is = isOption; -Option.safe = safe; -Option.all = all; -Option.any = any; +/** + * Creates a new Option which is `Some` when the provided `val` is a + * finite integer greater than or equal to 0. + * + * ``` + * const x = Option.qty("test".indexOf("s")); + * assert.equal(x.unwrap(), 2); + * + * const x = Option.qty("test".indexOf("z")); + * assert.equal(x.isNone(), true); + * ``` + */ +function qty(val: T): Option { + return val >= 0 && Number.isInteger(val) ? Some(val) : None; +} /** * Capture the outcome of a function or Promise as an `Option`, preventing @@ -152,14 +566,14 @@ function safe( ): Option | Promise> { if (fn instanceof Promise) { return fn.then( - (value) => Some(value), + (val) => Some(val), () => None ); } try { return Some(fn(...args)); - } catch (err) { + } catch { return None; } } @@ -218,33 +632,8 @@ function any[]>( ): Option[number]> { for (const option of options) { if (option.isSome()) { - return option; + return option as Option[number]>; } } return None; } - -function guard(opt: Option): U { - if (opt.isSome()) { - return opt.unwrapUnchecked() as U; - } else { - throw OptionExit; - } -} - -guard.bubble = (err: unknown) => { - if (err === OptionExit) { - throw err; - } -}; - -class GuardedOptionExit {} - -Object.freeze(GuardedOptionExit.prototype); -Object.freeze(Option); -Object.freeze(guard); -Object.freeze(safe); -Object.freeze(all); -Object.freeze(any); - -const OptionExit = Object.freeze(new GuardedOptionExit()); diff --git a/src/result.ts b/src/result.ts index 1c827bb..795b505 100644 --- a/src/result.ts +++ b/src/result.ts @@ -1,12 +1,11 @@ -import { Result as BaseResult, isResult, Ok, Err } from "./monad/result"; -export { Ok, Err } from "./monad/result"; +import { T, Val, EmptyArray, IterType, FalseyValues, isTruthy } from "./common"; +import { Option, Some, None } from "./option"; -export type Result = BaseResult; +export type Ok = ResultType; +export type Err = ResultType; +export type Result = ResultType; -export interface ResultGuard { - (res: Result): U; - bubble(err: unknown): void; -} +type From = Exclude; type ResultTypes = { [K in keyof R]: R[K] extends Result ? T : never; @@ -16,91 +15,588 @@ type ResultErrors = { [K in keyof R]: R[K] extends Result ? U : never; }; +export class ResultType { + readonly [T]: boolean; + readonly [Val]: T | E; + + constructor(val: T | E, ok: boolean) { + this[Val] = val; + this[T] = ok; + } + + [Symbol.iterator](this: Result): IterType { + return this[T] + ? (this[Val] as any)[Symbol.iterator]() + : EmptyArray[Symbol.iterator](); + } + + /** + * Returns the contained `T`, or `err` if the result is `Err`. The `err` + * value must be falsey and defaults to `undefined`. + * + * ``` + * const x = Ok(1); + * assert.equal(x.into(), 1); + * + * const x = Err(1); + * assert.equal(x.into(), undefined); + * + * const x = Err(1); + * assert.equal(x.into(null), null); + * ``` + */ + into(this: Result): T | undefined; + into(this: Result, err: U): T | U; + into(this: Result, err?: FalseyValues): T | FalseyValues { + return this[T] ? (this[Val] as T) : err; + } + + /** + * Compares the Result to `cmp`, returns true if both are `Ok` or both + * are `Err` and acts as a type guard. + * + * ``` + * const o = Ok(1); + * const e = Err(1); + * + * assert.equal(o.isLike(Ok(1))), true); + * assert.equal(e.isLike(Err(1)), true); + * assert.equal(o.isLike(e), false); + * ``` + */ + isLike(this: Result, cmp: unknown): cmp is Result { + return cmp instanceof ResultType && this[T] === cmp[T]; + } + + /** + * Returns true if the Result is `Ok` and acts as a type guard. + * + * ``` + * const x = Ok(10); + * assert.equal(x.isOk(), true); + * + * const x = Err(10); + * assert.equal(x.isOk(), false); + * ``` + */ + isOk(this: Result): this is Ok { + return this[T]; + } + + /** + * Returns true if the Result is `Err` and acts as a type guard. + * + * ``` + * const x = Ok(10); + * assert.equal(x.isErr(), false); + * + * const x = Err(10); + * assert.equal(x.isErr(), true); + * ``` + */ + isErr(this: Result): this is Err { + return !this[T]; + } + + /** + * Creates an `Option` by calling `f` with the contained `Ok` value. + * Converts `Ok` to `Some` if the filter returns true, or `None` otherwise. + * + * For more advanced filtering, consider `match`. + * + * ``` + * const x = Ok(1); + * assert.equal(x.filter((v) => v < 5).isLike(Some(1)), true); + * assert.equal(x.filter((v) => v < 5).unwrap(), 1); + * + * const x = Ok(10); + * assert.equal(x.filter((v) => v < 5).isNone(), true); + * + * const x = Err(1); + * assert.equal(x.filter((v) => v < 5).isNone(), true); + * ``` + */ + filter(this: Result, f: (val: T) => boolean): Option { + return this[T] && f(this[Val] as T) ? Some(this[Val] as T) : None; + } + + /** + * Returns the contained `Ok` value and throws `Error(msg)` if `Err`. + * + * To avoid throwing, consider `isOk`, `unwrapOr`, `unwrapOrElse` or + * `match` to handle the `Err` case. + * + * ``` + * const x = Ok(1); + * assert.equal(x.expect("Was Err"), 1); + * + * const x = Err(1); + * const y = x.expect("Was Err"); // throws + * ``` + */ + expect(this: Result, msg: string): T { + if (this[T]) { + return this[Val] as T; + } else { + throw new Error(msg); + } + } + + /** + * Returns the contained `Err` value and throws `Error(msg)` if `Ok`. + * + * To avoid throwing, consider `isErr` or `match` to handle the `Ok` case. + * + * ``` + * const x = Ok(1); + * const y = x.expectErr("Was Ok"); // throws + * + * const x = Err(1); + * assert.equal(x.expectErr("Was Ok"), 1); + * ``` + */ + expectErr(this: Result, msg: string): E { + if (this[T]) { + throw new Error(msg); + } else { + return this[Val] as E; + } + } + + /** + * Returns the contained `Ok` value and throws if `Err`. + * + * To avoid throwing, consider `isOk`, `unwrapOr`, `unwrapOrElse` or + * `match` to handle the `Err` case. To throw a more informative error use + * `expect`. + * + * ``` + * const x = Ok(1); + * assert.equal(x.unwrap(), 1); + * + * const x = Err(1); + * const y = x.unwrap(); // throws + * ``` + */ + unwrap(this: Result): T { + return this.expect("Failed to unwrap Result (found Err)"); + } + + /** + * Returns the contained `Err` value and throws if `Ok`. + * + * To avoid throwing, consider `isErr` or `match` to handle the `Ok` case. + * To throw a more informative error use `expectErr`. + * + * ``` + * const x = Ok(1); + * const y = x.unwrap(); // throws + * + * const x = Err(1); + * assert.equal(x.unwrap(), 1); + * ``` + */ + unwrapErr(this: Result): E { + return this.expectErr("Failed to unwrapErr Result (found Ok)"); + } + + /** + * Returns the contained `Ok` value or a provided default. + * + * The provided default is eagerly evaluated. If you are passing the result + * of a function call, consider `unwrapOrElse`, which is lazily evaluated. + * + * ``` + * const x = Ok(10); + * assert.equal(x.unwrapOr(1), 10); + * + * const x = Err(10); + * assert.equal(x.unwrapOr(1), 1); + * ``` + */ + unwrapOr(this: Result, def: T): T { + return this[T] ? (this[Val] as T) : def; + } + + /** + * Returns the contained `Ok` value or computes it from a function. + * + * ``` + * const x = Ok(10); + * assert.equal(x.unwrapOrElse(() => 1 + 1), 10); + * + * const x = Err(10); + * assert.equal(x.unwrapOrElse(() => 1 + 1), 2); + * ``` + */ + unwrapOrElse(this: Result, f: () => T): T { + return this[T] ? (this[Val] as T) : f(); + } + + /** + * Returns the contained `Ok` or `Err` value. + * + * Most problems are better solved using one of the other `unwrap_` methods. + * This method should only be used when you are certain that you need it. + * + * ``` + * const x = Ok(10); + * assert.equal(x.unwrapUnchecked(), 10); + * + * const x = Err(20); + * assert.equal(x.unwrapUnchecked(), 20); + * ``` + */ + unwrapUnchecked(this: Result): T | E { + return this[Val]; + } + + /** + * Returns the Option if it is `Ok`, otherwise returns `resb`. + * + * `resb` is eagerly evaluated. If you are passing the result of a function + * call, consider `orElse`, which is lazily evaluated. + * + * ``` + * const x = Ok(10); + * const xor = x.or(Ok(1)); + * assert.equal(xor.unwrap(), 10); + * + * const x = Err(10); + * const xor = x.or(Ok(1)); + * assert.equal(xor.unwrap(), 1); + * ``` + */ + or(this: Result, resb: Result): Result { + return this[T] ? (this as any) : resb; + } + + /** + * Returns the Result if it is `Ok`, otherwise returns the value of `f()` + * mapping `Result` to `Result`. + * + * ``` + * const x = Ok(10); + * const xor = x.orElse(() => Ok(1)); + * assert.equal(xor.unwrap(), 10); + * + * const x = Err(10); + * const xor = x.orElse(() => Ok(1)); + * assert.equal(xor.unwrap(), 1); + * + * const x = Err(10); + * const xor = x.orElse((e) => Err(`val ${e}`)); + * assert.equal(xor.unwrapErr(), "val 10"); + * ``` + */ + orElse(this: Result, f: (err: E) => Result): Result { + return this[T] ? (this as unknown as Result) : f(this[Val] as E); + } + + /** + * Returns itself if the Result is `Err`, otherwise returns `resb`. + * + * ``` + * const x = Ok(10); + * const xand = x.and(Ok(1)); + * assert.equal(xand.unwrap(), 1); + * + * const x = Err(10); + * const xand = x.and(Ok(1)); + * assert.equal(xand.unwrapErr(), 10); + * + * const x = Ok(10); + * const xand = x.and(Err(1)); + * assert.equal(xand.unwrapErr(), 1); + * ``` + */ + and(this: Result, resb: Result): Result { + return this[T] ? resb : (this as any); + } + + /** + * Returns itself if the Result is `Err`, otherwise calls `f` with the `Ok` + * value and returns the result. + * + * ``` + * const x = Ok(10); + * const xand = x.andThen((n) => n + 1); + * assert.equal(xand.unwrap(), 11); + * + * const x = Err(10); + * const xand = x.andThen((n) => n + 1); + * assert.equal(xand.unwrapErr(), 10); + * + * const x = Ok(10); + * const xand = x.and(Err(1)); + * assert.equal(xand.unwrapErr(), 1); + * ``` + */ + andThen(this: Result, f: (val: T) => Result): Result { + return this[T] ? f(this[Val] as T) : (this as any); + } + + /** + * Maps a `Result` to `Result` by applying a function to the + * `Ok` value. + * + * ``` + * const x = Ok(10); + * const xmap = x.map((n) => `number ${n}`); + * assert.equal(xmap.unwrap(), "number 10"); + * ``` + */ + map(this: Result, f: (val: T) => U): Result { + return new ResultType( + this[T] ? f(this[Val] as T) : (this[Val] as E), + this[T] + ) as Result; + } + + /** + * Maps a `Result` to `Result` by applying a function to the + * `Err` value. + * + * ``` + * const x = Err(10); + * const xmap = x.mapErr((n) => `number ${n}`); + * assert.equal(xmap.unwrapErr(), "number 10"); + * ``` + */ + mapErr(this: Result, op: (err: E) => F): Result { + return new ResultType( + this[T] ? (this[Val] as T) : op(this[Val] as E), + this[T] + ) as Result; + } + + /** + * Returns the provided default if `Err`, otherwise calls `f` with the + * `Ok` value and returns the result. + * + * The provided default is eagerly evaluated. If you are passing the result + * of a function call, consider `mapOrElse`, which is lazily evaluated. + * + * ``` + * const x = Ok(10); + * const xmap = x.mapOr(1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 11); + * + * const x = Err(10); + * const xmap = x.mapOr(1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 1); + * ``` + */ + mapOr(this: Result, def: U, f: (val: T) => U): U { + return this[T] ? f(this[Val] as T) : def; + } + + /** + * Computes a default return value if `Err`, otherwise calls `f` with the + * `Ok` value and returns the result. + * + * ``` + * const x = Ok(10); + * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 11); + * + * const x = Err(10); + * const xmap = x.mapOrElse(() => 1 + 1, (n) => n + 1); + * assert.equal(xmap.unwrap(), 2); + * ``` + */ + mapOrElse(this: Result, def: (err: E) => U, f: (val: T) => U): U { + return this[T] ? f(this[Val] as T) : def(this[Val] as E); + } + + /** + * Transforms the `Result` into an `Option`, mapping `Ok(v)` to + * `Some(v)`, discarding any `Err` value and mapping to None. + * + * ``` + * const x = Ok(10); + * const opt = x.ok(); + * assert.equal(x.isSome(), true); + * assert.equal(x.unwrap(), 10); + * + * const x = Err(10); + * const opt = x.ok(); + * assert.equal(x.isNone(), true); + * const y = x.unwrap(); // throws + * ``` + */ + ok(this: Result): Option { + return this[T] ? Some(this[Val] as T) : None; + } +} + +/** + * Tests the provided `val` is an Result and acts as a type guard. + * + * ``` + * assert.equal(Result.is(Ok(1), true); + * assert.equal(Result.is(Err(1), true)); + * assert.equal(Result.is(Some(1), false)); + * ``` + */ +function is(val: unknown): val is Result { + return val instanceof ResultType; +} + /** * A Result represents success, or failure. If we hold a value - * of type `Result`, we know it is either `Ok` or `Err`. + * of type `Result`, we know it is either `Ok` or `Err`. + * + * As a function, `Result` is an alias for `Result.from`. * * ``` - * const users = ["Simon", "Garfunkel"]; + * const users = ["Fry", "Bender"]; * function fetch_user(username: string): Result { - * return users.includes(username) - * ? Ok(username) - * : Err("*silence*"); + * return users.includes(username) ? Ok(username) : Err("Wha?"); * } * * function greet(username: string): string { * return fetch_user(username).mapOrElse( * (err) => `Error: ${err}`, - * (user) => `Hello ${user}, my old friend!` + * (user) => `Good news everyone, ${user} is here!` * ); * } * - * assert.equal(greet("Simon"), "Hello Simon, my old friend!") - * assert.equal(greet("SuperKing77"), "Error: *silence*"); + * assert.equal(greet("Bender"), "Good news everyone, Bender is here!"); + * assert.equal(greet("SuperKing"), "Error: Wha?"); * ``` + */ +export function Result( + val: T +): Result< + From, + | (T extends Error ? T : never) + | (Extract extends never ? never : null) +> { + return from(val) as any; +} + +Result.is = is; +Result.from = from; +Result.nonNull = nonNull; +Result.qty = qty; +Result.safe = safe; +Result.all = all; +Result.any = any; + +/** + * Creates an `Ok` value, which can be used where a `Result` is + * required. See Result for more examples. * - * ### Guarded Function Helper - * ## DEPRECATED + * Note that the counterpart `Err` type `E` is set to the same type as `T` + * by default. TypeScript will usually infer the correct `E` type from the + * context (e.g. a function which accepts or returns a Result). * - * This functionality will be removed in version 1.0.0. + * ``` + * const x = Ok(10); + * assert.equal(x.isSome(), true); + * assert.equal(x.unwrap(), 10); + * ``` + */ +export function Ok(val: T): Ok { + return new ResultType(val, true); +} + +/** + * Creates an `Err` value, which can be used where a `Result` is + * required. See Result for more examples. * - * Calling `Result(fn)` creates a new function with a `ResultGuard` helper. - * The guard lets you quickly and safely unwrap other `Result` values - * (providing that they have the same `E` type), and causes the function to - * return early with `Err` if an unwrap fails. A function create in this way - * always returns a `Result`. + * Note that the counterpart `Ok` type `T` is set to the same type as `E` + * by default. TypeScript will usually infer the correct `T` type from the + * context (e.g. a function which accepts or returns a Result). * - * Note: If you intend to use `try`/`catch` inside this function, see - * tests/examples/guard-bubbling.ts for some possible pit-falls. + * ``` + * const x = Err(10); + * assert.equal(x.isErr(), true); + * assert.equal(x.unwrapErr(), 10); + * ``` + */ +export function Err(val: E): Err { + return new ResultType(val, false); +} + +/** + * Creates a new `Result` which is `Ok` unless the provided `val` is + * falsey, an instance of `Error` or an invalid `Date`. + * + * The `T` is narrowed to exclude any falsey values or Errors. + * + * The `E` type includes: + * - `null` (if `val` could have been falsey or an invalid date) + * - `Error` types excluded from `T` (if there are any) + * + * **Note:** `null` is not a useful value. Consider `Option.from` or `mapErr`. * * ``` - * function to_pos(pos: number): Result { - * return pos > 0 && pos < 100 - * ? Ok(pos * 10) - * : Err("Invalid Pos"); - * } + * assert.equal(Result.from(1).unwrap(), 1); + * assert.equal(Result(0).isErr(), true); * - * // (x: number, y: number) => Result<{ x: number; y: number }, string>; - * const get_pos = Result((guard: Guard, x: number, y: number) => { - * return Ok({ - * x: guard(to_pos(x)), - * y: guard(to_pos(y)), - * }); - * }); + * const err = Result.from(new Error("msg")); + * assert.equal(err.unwrapErr().message, "msg"); * - * function show_pos(x: number, y: number): string { - * return get_pos(x, y).mapOrElse( - * (err) => `Error: ${err}`, - * ({ x, y }) => `Pos (${x},${y})` - * ); - * } + * // Create a Result + * const x = Option.from(1).okOr("Falsey Value"); + * ``` + */ +function from( + val: T +): Result< + From, + | (T extends Error ? T : never) + | (Extract extends never ? never : null) +> { + return isTruthy(val) + ? new ResultType(val as any, !(val instanceof Error)) + : Err(null); +} + +/** + * Creates a new `Result` which is `Ok` unless the provided `val` is + * `undefined`, `null` or `NaN`. + * + * **Note:** `null` is not a useful value. Consider `Option.nonNull` or + * `mapErr`. + * + * ``` + * assert.equal(Result.nonNull(1).unwrap(), 1); + * assert.equal(Result.nonNull(0).unwrap(), 0); + * assert.equal(Result.nonNull(null).isErr(), true); * - * assert.equal(show_pos(10, 20), "Pos (100,200)"); - * assert.equal(show_pos(1, 99), "Pos (10,990)"); - * assert.equal(show_pos(0, 50), "Error: Invalid Pos"); - * assert.equal(show_pos(50, 100), "Error: Invalid Pos"); + * // Create a Result + * const x = Option.nonNull(1).okOr("Nullish Value"); * ``` */ -export function Result( - fn: (guard: ResultGuard, ...args: A) => Result -): (...args: A) => Result { - return (...args) => { - try { - return fn(guard, ...args); - } catch (err) { - if (err instanceof GuardedResultExit) { - return err.result; - } else { - throw err; - } - } - }; +function nonNull(val: T): Result, null> { + return val === undefined || val === null || val !== val + ? Err(null) + : Ok(val as NonNullable); } -Result.is = isResult; -Result.safe = safe; -Result.all = all; -Result.any = any; +/** + * Creates a new Result which is `Ok` when the provided `val` is + * a finite integer greater than or equal to 0. + * + * **Note:** `null` is not a useful value. Consider `Option.qty` or `mapErr`. + * + * ``` + * const x = Result.qty("test".indexOf("s")); + * assert.equal(x.unwrap(), 2); + * + * const x = Result.qty("test".indexOf("z")); + * assert.equal(x.unwrapErr(), null); + * + * // Create a Result + * const x = Result.qty("test".indexOf("s")).mapErr(() => "Not Found"); + * ``` + */ +function qty(val: T): Result { + return val >= 0 && Number.isInteger(val) ? Ok(val) : Err(null); +} /** * Capture the outcome of a function or Promise as a `Result`, @@ -157,7 +653,6 @@ Result.any = any; * assert.equal(x.unwrap(), "Hello World"); * ``` */ - function safe( fn: (...args: A) => T extends PromiseLike ? never : T, ...args: A @@ -168,20 +663,20 @@ function safe( ...args: A ): Result | Promise> { if (fn instanceof Promise) { - return fn.then( - (value) => Ok(value), - (err) => - err instanceof Error ? Err(err) : Err(new Error(String(err))) - ); + return fn.then((val) => Ok(val), toError); } try { return Ok(fn(...args)); } catch (err) { - return err instanceof Error ? Err(err) : Err(new Error(String(err))); + return toError(err); } } +function toError(err: unknown): Err { + return err instanceof Error ? Err(err) : Err(new Error(String(err))); +} + /** * Converts a number of `Result`s into a single Result. The first `Err` found * (if any) is returned, otherwise the new Result is `Ok` and contains an array @@ -215,7 +710,7 @@ function all[]>( } } - return Ok(ok) as Ok, any>; + return Ok(ok) as Ok>; } /** @@ -250,34 +745,5 @@ function any[]>( } } - return Err(err) as Err, any>; -} - -function guard(res: Result): U { - if (res.isOk()) { - return res.unwrapUnchecked() as U; - } else { - throw new GuardedResultExit(res); - } + return Err(err) as Err>; } - -guard.bubble = (err: unknown) => { - if (err instanceof GuardedResultExit) { - throw err; - } -}; - -class GuardedResultExit { - result: E; - constructor(result: E) { - this.result = result; - Object.freeze(this); - } -} - -Object.freeze(GuardedResultExit.prototype); -Object.freeze(Result); -Object.freeze(guard); -Object.freeze(safe); -Object.freeze(all); -Object.freeze(any); diff --git a/tests/docs/suite/match.test.ts b/tests/docs/suite/match.test.ts index f2e7e0e..e435d09 100644 --- a/tests/docs/suite/match.test.ts +++ b/tests/docs/suite/match.test.ts @@ -1,147 +1,218 @@ -import { expect } from "chai"; +import { assert } from "chai"; import { Option, Some, - SomeIs, None, Result, Ok, Err, match, + Fn, _, } from "../../../src"; -export default function match_tests() { - { - const num: Option = Some(10); - const res = match(num, { - Some: (n) => n + 1, - None: () => 0, - }); +export default function matchDocs() { + it("Mapped", mappedMatchBasic); + it("Nested Mapped", mappedMatchNested); + it("Combined", combinedMatch); + it("Chained Primitive", chainedMatchPrimitive); + it("Chained Filter Function", chainedMatchFilterFunction); + it("Chained Object", chainedMatchObject); + it("Chained Array", chainedMatchArray); + it("Chained Monad", chainedMatchMonad); + it("Chained Fn", chainedMatchFn); + it("Compile", compileMatch); +} - it("Basic Matching (mapped)", () => expect(res).to.equal(11)); - } +function mappedMatchBasic() { + const num = Option(10); + const res = match(num, { + Some: (n) => n + 1, + None: () => 0, + }); + + assert.equal(res, 11); +} - { - const matchNest = (input: Result, string>) => - match(input, { - Ok: match({ - Some: (n) => `num ${n}`, - }), - _: () => "nothing", - }); - - it("Basic Matching (mapped 2)", () => { - expect(matchNest(Ok(Some(10)))).to.equal("num 10"); - expect(matchNest(Ok(None))).to.equal("nothing"); - expect(matchNest(Err("none"))).to.equal("nothing"); +function mappedMatchNested() { + function nested(val: Result, string>): string { + return match(val, { + Ok: { Some: (num) => `found ${num}` }, + _: () => "nothing", }); } - { - const matchNum = (num: number) => - match(num, [ - [5, "five"], - [(n) => n > 100, "big number"], - [(n) => n < 0, (n) => `negative ${n}`], - () => "other", - ]); - - it("Basic Matching (number)", () => { - expect(matchNum(5)).to.equal("five"); - expect(matchNum(150)).to.equal("big number"); - expect(matchNum(-20)).to.equal("negative -20"); - expect(matchNum(50)).to.equal("other"); + assert.equal(nested(Ok(Some(10))), "found 10"); + assert.equal(nested(Ok(None)), "nothing"); + assert.equal(nested(Err("Not a number")), "nothing"); +} + +function combinedMatch() { + function matchNum(val: Option): string { + return match(val, { + Some: [ + [5, "5"], + [(x) => x < 10, "< 10"], + [(x) => x > 20, "> 20"], + ], + _: () => "none or not matched", }); } - { - const matchObj = (obj: { a: number; b: { c: number } }) => - match(obj, [ - [{ a: 5 }, "a is 5"], - [{ b: { c: 5 } }, "c is 5"], - [{ a: 10, b: { c: (n) => n > 10 } }, "a 10 c gt 10"], - () => "other", - ]); - - it("Basic Matching (object)", () => { - expect(matchObj({ a: 5, b: { c: 5 } })).to.equal("a is 5"); - expect(matchObj({ a: 50, b: { c: 5 } })).to.equal("c is 5"); - expect(matchObj({ a: 10, b: { c: 20 } })).to.equal("a 10 c gt 10"); - expect(matchObj({ a: 8, b: { c: 8 } })).to.equal("other"); - }); + assert.equal(matchNum(Some(5)), "5"); + assert.equal(matchNum(Some(7)), "< 10"); + assert.equal(matchNum(Some(25)), "> 20"); + assert.equal(matchNum(Some(15)), "none or not matched"); + assert.equal(matchNum(None), "none or not matched"); +} + +function chainedMatchPrimitive() { + function matchNum(num: number): string { + return match(num, [ + [5, "five"], + [10, "ten"], + [15, (x) => `fifteen (${x})`], + () => "other", + ]); } - { - const matchArr = (arr: number[]) => - match(arr, [ - [[1], "1"], - [[2, (n) => n > 10], "2 gt10"], - [[_, 6, _, 12], "_ 6 _ 12"], - () => "other", - ]); - - it("Basic Matching (array)", () => { - expect(matchArr([1, 2, 3])).to.equal("1"); - expect(matchArr([2, 12, 6])).to.equal("2 gt10"); - expect(matchArr([3, 6, 9, 12])).to.equal("_ 6 _ 12"); - expect(matchArr([2, 4, 6])).to.equal("other"); - }); + assert.equal(matchNum(5), "five"); + assert.equal(matchNum(10), "ten"); + assert.equal(matchNum(15), "fifteen (15)"); + assert.equal(matchNum(20), "other"); +} + +function chainedMatchFilterFunction() { + function matchNum(num: number): string { + return match(num, [ + [5, "five"], + [(x) => x < 20, "< 20"], + [(x) => x > 30, "> 30"], + () => "other", + ]); } - interface Player { - name: string; - age: number; - status: string; + assert.equal(matchNum(5), "five"); + assert.equal(matchNum(15), "< 20"); + assert.equal(matchNum(50), "> 30"); + assert.equal(matchNum(25), "other"); +} + +function chainedMatchObject() { + interface ExampleObj { + a: number; + b?: { c: number }; + o?: number; } - const player1: Player = { name: "Paul", age: 80, status: "ok" }; - const player2: Player = { name: "SuperKing77", age: 12, status: "ok" }; - const player3: Player = { name: "BadGuy99", age: 24, status: "banned" }; + function matchObj(obj: ExampleObj): string { + return match(obj, [ + [{ a: 5 }, "a = 5"], + [{ b: { c: 5 } }, "c = 5"], + [{ a: 10, o: _ }, "a = 10, o = _"], + [{ a: 15, b: { c: (n) => n > 10 } }, "a = 15; c > 10"], + () => "other", + ]); + } - function can_proceed_1(player: Option): boolean { - return match(player, { - Some: (pl) => pl.age >= 18 && pl.status !== "banned", - None: () => false, - }); + assert.equal(matchObj({ a: 5 }), "a = 5"); + assert.equal(matchObj({ a: 50, b: { c: 5 } }), "c = 5"); + assert.equal(matchObj({ a: 10 }), "other"); + assert.equal(matchObj({ a: 10, o: 1 }), "a = 10, o = _"); + assert.equal(matchObj({ a: 15, b: { c: 20 } }), "a = 15; c > 10"); + assert.equal(matchObj({ a: 8, b: { c: 8 }, o: 1 }), "other"); +} + +function chainedMatchArray() { + function matchArr(arr: number[]): string { + return match(arr, [ + [[1], "1"], + [[2, (x) => x > 10], "2, > 10"], + [[_, 6, 9, _], (a) => a.join(", ")], + () => "other", + ]); } - it("can_proceed (1)", () => { - expect(can_proceed_1(Some(player1))).to.be.true; - expect(can_proceed_1(Some(player2))).to.be.false; - expect(can_proceed_1(Some(player3))).to.be.false; - expect(can_proceed_1(None)).to.be.false; - }); + assert.equal(matchArr([1, 2, 3]), "1"); + assert.equal(matchArr([2, 12, 6]), "2, > 10"); + assert.equal(matchArr([3, 6, 9]), "other"); + assert.equal(matchArr([3, 6, 9, 12]), "3, 6, 9, 12"); + assert.equal(matchArr([2, 4, 6]), "other"); +} - function can_proceed_2(player: Option): boolean { - return match(player, { - Some: [ - [{ status: "banned" }, false], - [{ age: (n) => n > 18 }, true], - ], - _: () => false, - }); +function chainedMatchMonad() { + type NumberMonad = Option | Result; + function matchMonad(val: NumberMonad): string { + return match(val, [ + [Some(1), "Some"], + [Ok(1), "Ok"], + [Err(1), "Err"], + () => "None", + ]); } - it("can_proceed (2)", () => { - expect(can_proceed_2(Some(player1))).to.be.true; - expect(can_proceed_2(Some(player2))).to.be.false; - expect(can_proceed_2(Some(player3))).to.be.false; - expect(can_proceed_2(None)).to.be.false; - }); + assert.equal(matchMonad(Some(1)), "Some"); + assert.equal(matchMonad(Ok(1)), "Ok"); + assert.equal(matchMonad(Err(1)), "Err"); + assert.equal(matchMonad(None), "None"); +} + +function chainedMatchFn() { + const fnOne = () => 1; + const fnTwo = () => 2; + const fnDefault = () => "fnDefault"; - function can_proceed_3(player: Option): boolean { - return match(player, [ - [Some({ status: "banned" }), false], - [SomeIs((pl) => pl.age >= 18), true], - () => false, + function matchFn(fnVal: (...args: any) => any): () => string { + return match(fnVal, [ + [Fn(fnOne), () => () => "fnOne"], + [Fn(fnTwo), Fn(() => "fnTwo")], + () => fnDefault, ]); } - it("can_proceed (3)", () => { - expect(can_proceed_3(Some(player1))).to.be.true; - expect(can_proceed_3(Some(player2))).to.be.false; - expect(can_proceed_3(Some(player3))).to.be.false; - expect(can_proceed_3(None)).to.be.false; + assert.equal(matchFn(fnOne)(), "fnOne"); + assert.equal(matchFn(fnTwo)(), "fnTwo"); + assert.equal(matchFn(() => 0)(), "fnDefault"); +} + +function compileMatch() { + it("Compile Mapped Match", compileMappedMatch); + it("Compile Chained Match", compileChainedMatch); + it("Compile Nested Match", compileNestedMatch); +} + +function compileMappedMatch() { + const matchSome = match.compile({ + Some: (n: number) => `got some ${n}`, + None: () => "got none", }); + + assert.equal(matchSome(Some(1)), "got some 1"); + assert.equal(matchSome(None), "got none"); +} + +function compileChainedMatch() { + const matchNum = match.compile([ + [1, "got 1"], + [2, "got 2"], + [(n) => n > 100, "got > 100"], + () => "default", + ]); + + assert.equal(matchNum(1), "got 1"); + assert.equal(matchNum(2), "got 2"); + assert.equal(matchNum(5), "default"); + assert.equal(matchNum(150), "got > 100"); +} + +function compileNestedMatch() { + type ResOpt = Result, number>; + const matchResOpt = match.compile({ + Ok: { Some: (s) => `some ${s}` }, + _: () => "default", + }); + + assert.equal(matchResOpt(Ok(Some("test"))), "some test"); + assert.equal(matchResOpt(Ok(None)), "default"); + assert.equal(matchResOpt(Err(1)), "default"); } diff --git a/tests/docs/suite/option.test.ts b/tests/docs/suite/option.test.ts index 6696602..4f6b04a 100644 --- a/tests/docs/suite/option.test.ts +++ b/tests/docs/suite/option.test.ts @@ -1,45 +1,99 @@ -import { expect } from "chai"; +import { assert } from "chai"; import { Option, Some, None } from "../../../src"; -export default function option() { - it("Main", () => { - const users = ["Simon", "Garfunkel"]; - function fetch_user(username: string): Option { - return users.includes(username) ? Some(username) : None; - } +export default function optionDocs() { + it("Option", optionMain); + it("Option.safe (function)", optionSafeFunction); + it("Option.safe (promise)", optionSafePromise); + it("Option.all", optionAll); + it("Option.any", optionAny); +} - function greet(username: string): string { - return fetch_user(username) - .map((user) => `Hello ${user}, my old friend!`) - .unwrapOr("*silence*"); - } +function optionMain() { + const users = ["Fry", "Bender"]; + function fetch_user(username: string): Option { + return users.includes(username) ? Some(username) : None; + } - expect(greet("Simon")).to.equal("Hello Simon, my old friend!"); - expect(greet("SuperKing777")).to.equal("*silence*"); - }); + function greet(username: string): string { + return fetch_user(username) + .map((user) => `Good news everyone, ${user} is here!`) + .unwrapOr("Wha?"); + } + + assert.equal(greet("Bender"), "Good news everyone, Bender is here!"); + assert.equal(greet("SuperKing"), "Wha?"); +} - it("Guarded Function", () => { - function to_pos(pos: number): Option { - return pos > 0 && pos < 100 ? Some(pos * 10) : None; +function optionSafeFunction() { + function mightThrow(throws: boolean) { + if (throws) { + throw new Error("Throw"); } + return "Hello World"; + } - // (x: number, y: number) => Option<{ x: number; y: number }>; - const get_pos = Option((guard, x: number, y: number) => { - return Some({ - x: guard(to_pos(x)), - y: guard(to_pos(y)), - }); - }); - - function show_pos(x: number, y: number): string { - return get_pos(x, y).mapOr( - "Invalid Pos", - ({ x, y }) => `Pos (${x},${y})` - ); + { + const x: Option = Option.safe(mightThrow, true); + assert.equal(x.isNone(), true); + } + + { + const x = Option.safe(() => mightThrow(false)); + assert.equal(x.unwrap(), "Hello World"); + } +} + +async function optionSafePromise() { + async function mightThrow(throws: boolean) { + if (throws) { + throw new Error("Throw"); } + return "Hello World"; + } + + { + const x = await Option.safe(mightThrow(true)); + assert.equal(x.isNone(), true); + } + + { + const x = await Option.safe(mightThrow(false)); + assert.equal(x.unwrap(), "Hello World"); + } +} + +function optionAll() { + function num(val: number): Option { + return val > 10 ? Some(val) : None; + } + + { + const xyz = Option.all(num(20), num(30), num(40)); + const [x, y, z] = xyz.unwrap(); + assert.equal(x, 20); + assert.equal(y, 30); + assert.equal(z, 40); + } + + { + const x = Option.all(num(20), num(5), num(40)); + assert.equal(x.isNone(), true); + } +} + +function optionAny() { + function num(val: number): Option { + return val > 10 ? Some(val) : None; + } + + { + const x = Option.any(num(5), num(20), num(2)); + assert.equal(x.unwrap(), 20); + } - expect(show_pos(10, 20)).to.equal("Pos (100,200)"); - expect(show_pos(-20, 50)).to.equal("Invalid Pos"); - expect(show_pos(50, 100)).to.equal("Invalid Pos"); - }); + { + const x = Option.any(num(2), num(5), num(8)); + assert.equal(x.isNone(), true); + } } diff --git a/tests/docs/suite/result.test.ts b/tests/docs/suite/result.test.ts index 2687b0b..42afda7 100644 --- a/tests/docs/suite/result.test.ts +++ b/tests/docs/suite/result.test.ts @@ -1,46 +1,98 @@ -import { expect } from "chai"; -import { Result, Guard, Ok, Err } from "../../../src"; - -export default function result() { - it("Result (fetch_user)", () => { - const users = ["Simon", "Garfunkel"]; - function fetch_user(username: string): Result { - return users.includes(username) ? Ok(username) : Err("*silence*"); - } +import { assert } from "chai"; +import { Result, Ok, Err } from "../../../src"; - function greet(username: string): string { - return fetch_user(username).mapOrElse( - (err) => `Error: ${err}`, - (user) => `Hello ${user}, my old friend!` - ); - } +export default function resultDocs() { + it("Result", resultMain); + it("Result.safe (function)", resultSafeFunction); + it("Result.safe (promise)", resultSafePromise); + it("Result.all", resultAll); + it("Result.any", resultAny); +} - expect(greet("Simon")).to.equal("Hello Simon, my old friend!"); - expect(greet("SuperKing777")).to.equal("Error: *silence*"); - }); +function resultMain() { + const users = ["Fry", "Bender"]; + function fetch_user(username: string): Result { + return users.includes(username) ? Ok(username) : Err("Wha?"); + } - it("Result (guard)", () => { - function to_pos(pos: number): Result { - return pos > 0 && pos < 100 ? Ok(pos * 10) : Err("Invalid Pos"); + function greet(username: string): string { + return fetch_user(username).mapOrElse( + (err) => `Error: ${err}`, + (user) => `Good news everyone, ${user} is here!` + ); + } + + assert.equal(greet("Bender"), "Good news everyone, Bender is here!"); + assert.equal(greet("SuperKing"), "Error: Wha?"); +} + +function resultSafeFunction() { + function mightThrow(throws: boolean) { + if (throws) { + throw new Error("Throw"); } + return "Hello World"; + } + + { + const x: Result = Result.safe(mightThrow, true); + assert.equal(x.unwrapErr() instanceof Error, true); + assert.equal(x.unwrapErr().message, "Throw"); + } + + { + const x = Result.safe(() => mightThrow(false)); + assert.equal(x.unwrap(), "Hello World"); + } +} - // (x: number, y: number) => Result<{ x: number; y: number }, string>; - const get_pos = Result((guard: Guard, x: number, y: number) => { - return Ok({ - x: guard(to_pos(x)), - y: guard(to_pos(y)), - }); - }); - - function show_pos(x: number, y: number): string { - return get_pos(x, y).mapOrElse( - (err) => `Error: ${err}`, - ({ x, y }) => `Pos (${x},${y})` - ); +async function resultSafePromise() { + async function mightThrow(throws: boolean) { + if (throws) { + throw new Error("Throw"); } + return "Hello World"; + } + + { + const x = await Result.safe(mightThrow(true)); + assert.equal(x.unwrapErr() instanceof Error, true); + assert.equal(x.unwrapErr().message, "Throw"); + } + + { + const x = await Result.safe(mightThrow(false)); + assert.equal(x.unwrap(), "Hello World"); + } +} + +function resultAll() { + function num(val: number): Result { + return val > 10 ? Ok(val) : Err(`Value ${val} is too low.`); + } + + const xyz = Result.all(num(20), num(30), num(40)); + const [x, y, z] = xyz.unwrap(); + assert.equal(x, 20); + assert.equal(y, 30); + assert.equal(z, 40); + + const err = Result.all(num(20), num(5), num(40)); + assert.equal(err.isErr(), true); + assert.equal(err.unwrapErr(), "Value 5 is too low."); +} + +function resultAny() { + function num(val: number): Result { + return val > 10 ? Ok(val) : Err(`Value ${val} is too low.`); + } + + const x = Result.any(num(5), num(20), num(2)); + assert.equal(x.unwrap(), 20); - expect(show_pos(10, 20)).to.equal("Pos (100,200)"); - expect(show_pos(-20, 50)).to.equal("Error: Invalid Pos"); - expect(show_pos(50, 100)).to.equal("Error: Invalid Pos"); - }); + const efg = Result.any(num(2), num(5), num(8)); + const [e, f, g] = efg.unwrapErr(); + assert.equal(e, "Value 2 is too low."); + assert.equal(f, "Value 5 is too low."); + assert.equal(g, "Value 8 is too low."); } diff --git a/tests/examples/guard-bubbling.ts b/tests/examples/guard-bubbling.ts deleted file mode 100644 index 38e1795..0000000 --- a/tests/examples/guard-bubbling.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect } from "chai"; -import { Option, Some, None } from "../../src"; - -/** - * If you're using try/catch inside a guarded function then you should - * whether you're using the best solution. This is a solution for if you - * really want to use the guard function inside a try/catch block. - * - * Guarded functions use a try/catch block and look for a very specific - * thrown value in order to implement early return. This means that if you - * use a try/catch block inside a guarded function then you can catch that - * value and prevent the early return happening. - * - * To get around this, calling `guard.bubble` will re-throw the error only - * if it is of that specific type. - */ - -const deny_list = [2, 7, 15]; - -const test_number = Option((guard, num: Option) => { - try { - const val = guard(num); - if (deny_list.includes(val)) { - throw new Error("That number is simply not acceptable."); - } - return Some(val); - } catch (err) { - guard.bubble(err); - return Some(0); - } -}); - -export default function suite() { - it("Bubbles real errors", () => { - expect(test_number(Some(10)).unwrap()).to.equal(10); - expect(() => test_number(None).unwrap()).to.throw(/unwrap/); - expect(test_number(Some(7)).unwrap()).to.equal(0); - }); -} diff --git a/tests/examples/index.ts b/tests/examples/index.ts deleted file mode 100644 index 315c200..0000000 --- a/tests/examples/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import number_facts from "./number-facts"; -import guard_bubbling from "./guard-bubbling"; -import player_tags from "./player-tags"; - -export default function examples() { - describe("Guard Bubbling", guard_bubbling); - describe("Number Facts", number_facts); - describe("Player Tags", player_tags); -} diff --git a/tests/examples/number-facts.ts b/tests/examples/number-facts.ts deleted file mode 100644 index 294a3f8..0000000 --- a/tests/examples/number-facts.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { expect } from "chai"; -import { Option, Result, Guard, match, Some, None, Ok, Err } from "../../src"; - -function fizzbuzz(num: number): string { - return match(num, [ - [(n) => n % 5 === 0 && n % 3 === 0, "fizzbuzz"], - [(n) => n % 5 === 0, "buzz"], - [(n) => n % 3 === 0, "fizz"], - () => num.toString(), - ]); -} - -function fifty_div(num: number): Result, string> { - return num === 0 - ? Err("div by 0") - : Ok(50 % num === 0 ? Some(50 / num) : None); -} - -const compute_facts = Result(($: Guard, input: Option) => { - const num = $(input.okOr("no number")); - return Ok([ - fizzbuzz(num), - $(fifty_div(num)).mapOr("not div", (res) => `div ${res}`), - ]); -}); - -function facts(input: Option): string { - return match(compute_facts(input), { - Ok: (facts) => facts.join(" "), - Err: (err) => err, - }); -} - -export default function suite() { - it("Handles valid cases", () => { - expect(facts(Some(1))).to.equal("1 div 50"); - expect(facts(Some(3))).to.equal("fizz not div"); - expect(facts(Some(5))).to.equal("buzz div 10"); - expect(facts(Some(15))).to.equal("fizzbuzz not div"); - expect(facts(Some(25))).to.equal("buzz div 2"); - }); - - it("Handles invalid cases", () => { - expect(facts(None)).to.equal("no number"); - expect(facts(Some(0))).to.equal("div by 0"); - }); -} diff --git a/tests/examples/player-tags.ts b/tests/examples/player-tags.ts deleted file mode 100644 index 8519df3..0000000 --- a/tests/examples/player-tags.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { expect } from "chai"; -import { Option, Result, Guard, Some, None, Ok, Err } from "../../src"; - -const players: Record = { - "player-one": 1234, - "player-two": 5678, - "player-three": 9123, -}; - -const tags: Record = { - 1234: "#p1", - 5678: "#p2", -}; - -function is_valid(pos: number): Result { - return pos > 10 && pos <= 100 ? Ok(pos) : Err("Number too large"); -} - -function player_id(player: string): Result { - return players.hasOwnProperty(player) - ? Ok(players[player]) - : Err("Not found"); -} - -function player_tag(id: number): Option { - return tags.hasOwnProperty(id) ? Some(tags[id]) : None; -} - -const pos = Option(($, left: Option, right: Option) => { - const [x, y] = [$(left), $(right)]; - $(is_valid(x).and(is_valid(y)).ok()); - return Some([x - 5, y - 5] as const); -}); - -const move = Result( - ($: Guard, player: string, x: Option, y: Option) => { - const id = $(player_id(player)); - const tag = player_tag(id).unwrapOrElse(() => `#default-${id}`); - - return pos(x, y) - .okOr("Missing position") - .map(([x, y]) => `move ${id} ${tag} to ${x} ${y}`); - } -); - -export default function suite() { - it("Player one", () => - expect(move("player-one", Some(50), Some(50)).unwrap()).to.equal( - "move 1234 #p1 to 45 45" - )); - - it("Player two", () => - expect(move("player-two", Some(20), Some(80)).unwrap()).to.equal( - "move 5678 #p2 to 15 75" - )); - - it("Player three", () => - expect(move("player-three", Some(30), Some(60)).unwrap()).to.equal( - "move 9123 #default-9123 to 25 55" - )); - - it("Player not found", () => - expect(move("player-none", Some(20), Some(40)).unwrapErr()).to.equal( - "Not found" - )); - - it("Position input isNone", () => { - expect(move("player-one", Some(50), None).unwrapErr()).to.equal( - "Missing position" - ); - expect(move("player-one", None, Some(50)).unwrapErr()).to.equal( - "Missing position" - ); - }); - - it("Position input is invalid", () => { - expect(move("player-one", Some(0), Some(50)).unwrapErr()).to.equal( - "Missing position" - ); - expect(move("player-one", Some(50), Some(110)).unwrapErr()).to.equal( - "Missing position" - ); - }); -} diff --git a/tests/index.ts b/tests/index.ts index d990b65..a537bdf 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -2,10 +2,8 @@ import option from "./option"; import result from "./result"; import match from "./match"; import docs from "./docs"; -import examples from "./examples"; describe("Option", option); describe("Result", result); describe("Match", match); describe("Docs", docs); -describe("Examples", examples); diff --git a/tests/match/index.ts b/tests/match/index.ts index dae276c..11beb38 100644 --- a/tests/match/index.ts +++ b/tests/match/index.ts @@ -4,9 +4,11 @@ import object from "./suite/object.test"; import array from "./suite/array.test"; import option from "./suite/option.test"; import result from "./suite/result.test"; +import union from "./suite/union.test"; import fn from "./suite/function.test"; -import nested from "./suite/nested.test"; -import a_sync from "./suite/async.test"; +import nesting from "./suite/nesting.test"; +import async_ from "./suite/async.test"; +import compile from "./suite/compile.test"; export default function match() { describe("Call Signature", call); @@ -15,7 +17,9 @@ export default function match() { describe("Arrays", array); describe("Option", option); describe("Result", result); + describe("Union (Option or Result)", union); describe("Fn within Option/Result", fn); - describe("Nested", nested); - describe("Async", a_sync); + describe("Nesting", nesting); + describe("Async", async_); + describe("Compile", compile); } diff --git a/tests/match/suite/array.test.ts b/tests/match/suite/array.test.ts index 13256e9..6112afb 100644 --- a/tests/match/suite/array.test.ts +++ b/tests/match/suite/array.test.ts @@ -1,44 +1,35 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { match, _ } from "../../../src"; export default function array() { - function matchArr(input: number[]): string { - return match(input, [ - [[2, _, 6], "2 _ 6"], + function test(val: number[]): string { + return match(val, [ + [[0, _], "0 _"], [[1, 2, 3], "1 2 3"], - [[2], "2"], [[1], "1"], + [[2], "2"], () => "default", ]); } it("Should match a single element", () => { - expect(matchArr([1])).to.equal("1"); - expect(matchArr([2])).to.equal("2"); - expect(matchArr([0])).to.equal("default"); + expect(test([1])).to.equal("1"); + expect(test([2])).to.equal("2"); + expect(test([3])).to.equal("default"); }); it("Should match multiple elements", () => { - expect(matchArr([1, 2, 3])).to.equal("1 2 3"); - expect(matchArr([1, 2, 4])).to.equal("1"); + expect(test([1, 2, 3])).to.equal("1 2 3"); + expect(test([1, 2, 4])).to.equal("1"); }); - it("Should skip elements with the _ value", () => - expect(matchArr([2, 4, 6])).to.equal("2 _ 6")); - it("Does not match an object", () => + it("Should require the key be present for _", () => + expect(test([0])).to.equal("default")); + it("Should allow any value for _", () => + expect(test([0, 10])).to.equal("0 _")); + it("Should not match an object", () => expect( - match({ "0": 1 } as any, [[[1], "match"], () => "default"]) + match({ "0": 1 } as any, [ + [[1], "match"], // + () => "default", + ]) ).to.equal("default")); } diff --git a/tests/match/suite/async.test.ts b/tests/match/suite/async.test.ts index d896fa5..54c4c26 100644 --- a/tests/match/suite/async.test.ts +++ b/tests/match/suite/async.test.ts @@ -1,40 +1,32 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { Option, Result, Some, None, Ok, Err, match } from "../../../src"; -export default function ________() { - function mappedAsync(input: Option>) { - return match(input, { - Some: match({ +export default function async_() { + describe("Mapped", mapped); + describe("Chained", chained); +} + +function mapped() { + function test(val: Option>) { + return match(val, { + Some: { Ok: async (str) => `ok ${str}`, Err: async (num) => `err ${num}`, - }), + }, _: async () => "none", }); } - it("Matches Some.Ok", async () => - expect(await mappedAsync(Some(Ok("test")))).to.equal("ok test")); - it("Matches Some.Err", async () => - expect(await mappedAsync(Some(Err(1)))).to.equal("err 1")); - it("Matches None", async () => - expect(await mappedAsync(None)).to.equal("none")); + it("Matches Ok within Some", async () => + expect(await test(Some(Ok("test")))).to.equal("ok test")); + it("Matches Err within Some", async () => + expect(await test(Some(Err(1)))).to.equal("err 1")); + it("Matches None", async () => expect(await test(None)).to.equal("none")); +} - function chainedAsync(input: Option>) { - return match(input, [ +function chained() { + function test(val: Option>) { + return match(val, [ [Some(Ok("test")), async () => `some ok`], [Some(Err(1)), async () => `some err`], [None, async () => "none"], @@ -42,12 +34,11 @@ export default function ________() { ]); } - it("Matches Some.Ok", async () => - expect(await chainedAsync(Some(Ok("test")))).to.equal("some ok")); - it("Matches Some.Err", async () => - expect(await chainedAsync(Some(Err(1)))).to.equal("some err")); - it("Matches None", async () => - expect(await chainedAsync(None)).to.equal("none")); + it("Matches Ok within Some", async () => + expect(await test(Some(Ok("test")))).to.equal("some ok")); + it("Matches Err within Some", async () => + expect(await test(Some(Err(1)))).to.equal("some err")); + it("Matches None", async () => expect(await test(None)).to.equal("none")); it("Returns the default when there is no match", async () => - expect(await chainedAsync(Some(Ok("no match")))).to.equal("no match")); + expect(await test(Some(Ok("no match")))).to.equal("no match")); } diff --git a/tests/match/suite/call.test.ts b/tests/match/suite/call.test.ts index 7137fe2..1c931c3 100644 --- a/tests/match/suite/call.test.ts +++ b/tests/match/suite/call.test.ts @@ -1,26 +1,22 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { match, _, Default, Some } from "../../../src"; export default function call() { - it("Throws when input is neither Option or Result and pattern is not an array", () => - expect(() => match("test", {} as any)).to.throw(/call signature/)); - it("Throws when first-position pattern is not object-like", () => - expect(() => match(true as any)).to.throw(/call signature/)); it("Default and _ are the same", () => expect(_).to.equal(Default)); it("Default type throws the exhausted error", () => expect(() => _()).to.throw(/exhausted/)); + + it("Mapped matching without a pattern throws exhausted", () => + expect(() => match(Some(1), {})).to.throw(/exhausted/)); + it("Chained matching without a pattern throws exhausted", () => + expect(() => match("never", [])).to.throw(/exhausted/)); + + it("Throws when trying to use mapped matching on a non-monad", () => + expect( + () => match("never", { _: () => true } as any) // + ).to.throw(/invalid pattern/)); + it("Throws when the pattern is not object-like", () => + expect( + () => match(true as any, null as any) // + ).to.throw(/invalid pattern/)); } diff --git a/tests/match/suite/compile.test.ts b/tests/match/suite/compile.test.ts new file mode 100644 index 0000000..3ae0351 --- /dev/null +++ b/tests/match/suite/compile.test.ts @@ -0,0 +1,38 @@ +import { expect } from "chai"; +import { Some, None, Ok, Err, match } from "../../../src"; + +export default function compile() { + it("Should compile from mapped Option branches", () => { + const test = match.compile({ + Some: (n: number) => `some ${n}`, + None: () => "none", + }); + + expect(test(Some(1))).to.equal("some 1"); + expect(test(None)).to.equal("none"); + }); + + it("Should compile from mapped Result branches", () => { + const test = match.compile({ + Ok: (n: number) => `ok ${n}`, + Err: (s: string) => `err ${s}`, + }); + + expect(test(Ok(1))).to.equal("ok 1"); + expect(test(Err("test"))).to.equal("err test"); + }); + + it("Should compile from chained branches", () => { + const test = match.compile([ + [1, "one"], + [2, "two"], + [(n) => n > 20, ">20"], + () => "default", + ]); + + expect(test(1)).to.equal("one"); + expect(test(2)).to.equal("two"); + expect(test(30)).to.equal(">20"); + expect(test(5)).to.equal("default"); + }); +} diff --git a/tests/match/suite/function.test.ts b/tests/match/suite/function.test.ts index dfbf938..1fb5bb7 100644 --- a/tests/match/suite/function.test.ts +++ b/tests/match/suite/function.test.ts @@ -1,37 +1,58 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { Option, Some, match, Fn } from "../../../src"; export default function fn() { - let called = false; - const testFn = () => { - called = true; - return "test"; - }; + const first = createWatchFn("first"); + const second = createWatchFn("second"); - function functionMatch(input: Option<() => string>): string { + function test(input: Option<() => string>): string { return match(input, [ - [Some(Fn(testFn)), "test"], - () => "default", // + [Some(Fn(first)), "match-first"], + [Some(Fn(second)), "match-second"], + () => "default", ]); } it("Matches", () => { - expect(functionMatch(Some(testFn))).to.equal("test"); - expect(called).to.be.false; - expect(functionMatch(Some(() => "none"))).to.equal("default"); + expect(test(Some(first))).to.equal("match-first"); + expect(first.wasCalled()).to.be.false; + expect(test(Some(() => "none"))).to.equal("default"); + }); + + it("Does not call functions within Monads", () => { + expect(test(Some(second))).to.equal("match-second"); + expect(second.wasCalled()).to.be.false; + }); + + it("Does not call functions nested within monads", () => { + const nested = createWatchFn("nested"); + expect( + match(Some({ a: [1] }), [ + [Some({ a: [nested as any] }), "fail"], + () => "default", + ]) + ).to.equal("default"); + expect(nested.wasCalled()).to.be.false; }); + + it("Returns the wrapped function if the default branch is Fn", () => + expect( + match(first, [ + Fn(() => "default"), // default branch + ])() + ).to.equal("default")); + + it("Throws if the wrapped Fn is called", () => + expect(Fn(() => 1)).to.throw(/wrapped function called/)); +} + +function createWatchFn(returns: string): { (): string; wasCalled(): boolean } { + let called = false; + const fn = () => { + called = true; + return returns; + }; + + fn.wasCalled = () => called; + return fn; } diff --git a/tests/match/suite/nested.test.ts b/tests/match/suite/nested.test.ts deleted file mode 100644 index 324c955..0000000 --- a/tests/match/suite/nested.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; - -export default function nested() { - function mappedNested( - input: Result>, number> - ): string { - return match(input, { - Ok: match({ - Some: match({ - Ok: (n) => `ok ${n}`, - Err: (e) => `err b ${e}`, - }), - None: () => "none", - }), - Err: (e) => `err a ${e}`, - }); - } - - function commonDefault( - input: Result>, number> - ): string { - return match(input, { - Ok: match({ - Some: match({ - Ok: (n) => `ok ${n}`, - _: () => "inner default", - }), - }), - _: () => "default", - }); - } - - it("Matches", () => { - expect(mappedNested(Ok(Some(Ok(1))))).to.equal("ok 1"); - expect(mappedNested(Ok(Some(Err(1))))).to.equal("err b 1"); - expect(mappedNested(Ok(None))).to.equal("none"); - expect(mappedNested(Err(1))).to.equal("err a 1"); - }); - - it("Falls back to the closest default (_)", () => { - expect(commonDefault(Ok(Some(Ok(1))))).to.equal("ok 1"); - expect(commonDefault(Ok(Some(Err("_"))))).to.equal("inner default"); - expect(commonDefault(Ok(None))).to.equal("default"); - expect(commonDefault(Err(1))).to.equal("default"); - }); - - function chainNested( - input: Result>, number> - ): string { - return match(input, [ - [Ok(Some(Ok(1))), "ok some ok"], - [Ok(Some(Err("err"))), "ok some err"], - [Ok(None), "ok none"], - [Err(1), "err"], - () => "default", - ]); - } - - function chainCond( - input: Result>, number> - ): string { - return match(input, [ - [OkIs(SomeIs(OkIs((val) => val > 10))), "ok gt 10"], - [OkIs(SomeIs(ErrIs((err) => err.startsWith("a")))), "err a"], - [Ok(None), "ok none"], - [ErrIs((n) => n > 10), "err gt 10"], - () => "default", - ]); - } - - it("Matches", () => { - expect(chainNested(Ok(Some(Ok(1))))).to.equal("ok some ok"); - expect(chainNested(Ok(Some(Ok(2))))).to.equal("default"); - expect(chainNested(Ok(Some(Err("err"))))).to.equal("ok some err"); - expect(chainNested(Ok(Some(Err("nomatch"))))).to.equal("default"); - expect(chainNested(Ok(None))).to.equal("ok none"); - expect(chainNested(Err(1))).to.equal("err"); - expect(chainNested(Err(2))).to.equal("default"); - }); - - it("Matches based on conditions", () => { - expect(chainCond(Ok(Some(Ok(15))))).to.equal("ok gt 10"); - expect(chainCond(Ok(Some(Ok(5))))).to.equal("default"); - expect(chainCond(Ok(Some(Err("abc"))))).to.equal("err a"); - expect(chainCond(Ok(Some(Err("def"))))).to.equal("default"); - expect(chainCond(Ok(None))).to.equal("ok none"); - expect(chainCond(Err(15))).to.equal("err gt 10"); - expect(chainCond(Err(5))).to.equal("default"); - }); -} diff --git a/tests/match/suite/nesting.test.ts b/tests/match/suite/nesting.test.ts new file mode 100644 index 0000000..71ca7b9 --- /dev/null +++ b/tests/match/suite/nesting.test.ts @@ -0,0 +1,77 @@ +import { expect } from "chai"; +import { Option, Result, Some, None, Ok, Err, match } from "../../../src"; + +export default function nesting() { + describe("Mapped", mapped); + describe("Chained", chained); +} + +function mapped() { + mappedNestingTest(); + closestDefaultTest(); +} + +function mappedNestingTest() { + function test(val: Result>, number>): string { + return match(val, { + Ok: { + Some: { + Ok: (n) => `ok ${n}`, + Err: (e) => `err b ${e}`, + }, + None: () => "none", + }, + Err: (e) => `err a ${e}`, + }); + } + + it("Matches", () => { + expect(test(Ok(Some(Ok(1))))).to.equal("ok 1"); + expect(test(Ok(Some(Err(1))))).to.equal("err b 1"); + expect(test(Ok(None))).to.equal("none"); + expect(test(Err(1))).to.equal("err a 1"); + }); +} + +function closestDefaultTest() { + function test(val: Result>, number>): string { + return match(val, { + Ok: { + Some: { + Ok: (n) => `ok ${n}`, + _: () => "inner default", + }, + }, + _: () => "default", + }); + } + + it("Falls back to the closest default", () => { + expect(test(Ok(Some(Ok(1))))).to.equal("ok 1"); + expect(test(Ok(Some(Err("_"))))).to.equal("inner default"); + expect(test(Ok(None))).to.equal("default"); + expect(test(Err(1))).to.equal("default"); + }); +} + +function chained() { + function test(val: Result>, number>): string { + return match(val, [ + [Ok(Some(Ok(1))), "ok some ok"], + [Ok(Some(Err("err"))), "ok some err"], + [Ok(None), "ok none"], + [Err(1), "err"], + () => "default", + ]); + } + + it("Matches", () => { + expect(test(Ok(Some(Ok(1))))).to.equal("ok some ok"); + expect(test(Ok(Some(Ok(2))))).to.equal("default"); + expect(test(Ok(Some(Err("err"))))).to.equal("ok some err"); + expect(test(Ok(Some(Err("nomatch"))))).to.equal("default"); + expect(test(Ok(None))).to.equal("ok none"); + expect(test(Err(1))).to.equal("err"); + expect(test(Err(2))).to.equal("default"); + }); +} diff --git a/tests/match/suite/object.test.ts b/tests/match/suite/object.test.ts index ecfdb67..2211f38 100644 --- a/tests/match/suite/object.test.ts +++ b/tests/match/suite/object.test.ts @@ -1,23 +1,9 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { match, _ } from "../../../src"; export default function object() { - function matchObj(input: Record): string { - return match(input, [ + function test(val: Record): string { + return match(val, [ [{ a: 5, b: _, c: 1 }, "a 5 b _ c 1"], [{ a: 4, b: { c: 1 } }, "a 4 bc 1"], [{ a: 3, b: 1 }, "a 3 b 1"], @@ -28,26 +14,31 @@ export default function object() { } it("Should match a single key", () => { - expect(matchObj({ a: 1 })).to.equal("a 1"); - expect(matchObj({ a: 2 })).to.equal("a 2"); - expect(matchObj({ b: 1 })).to.equal("default"); - expect(matchObj({ a: 6 })).to.equal("default"); + expect(test({ a: 1 })).to.equal("a 1"); + expect(test({ a: 2 })).to.equal("a 2"); + expect(test({ b: 1 })).to.equal("default"); + expect(test({ a: 6 })).to.equal("default"); }); it("Should match multiple keys", () => { - expect(matchObj({ a: 3, b: 1 })).to.equal("a 3 b 1"); - expect(matchObj({ a: 3, b: 2 })).to.equal("default"); + expect(test({ a: 3, b: 1 })).to.equal("a 3 b 1"); + expect(test({ a: 3, b: 2 })).to.equal("default"); }); - it("Skips keys with the _ value", () => - expect(matchObj({ a: 5, b: 5, c: 1 })).to.equal("a 5 b _ c 1")); + it("Should require the key be present for _", () => + expect(test({ a: 5, c: 1 })).to.equal("default")); + it("Should allow any value for _", () => + expect(test({ a: 5, b: 5, c: 1 })).to.equal("a 5 b _ c 1")); it("Should match nested structures", () => expect( - matchObj({ + test({ a: 4, b: { c: 1 }, }) ).to.equal("a 4 bc 1")); it("Does not match an array", () => expect( - match([1] as any, [[{ "0": 1 }, "match"], () => "default"]) + match([1] as any, [ + [{ "0": 1 }, "match"], // + () => "default", + ]) ).to.equal("default")); } diff --git a/tests/match/suite/option.test.ts b/tests/match/suite/option.test.ts index 08c18bb..25de28d 100644 --- a/tests/match/suite/option.test.ts +++ b/tests/match/suite/option.test.ts @@ -1,46 +1,72 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { Option, Some, None, match, _ } from "../../../src"; export default function option() { - function mappedMatch(input: Option): string { - return match(input, { + describe("Mapped", mapped); + describe("Chained", chained); + describe("Combined (chained within mapped)", combined); +} + +function mapped() { + function test(val: Option): string { + return match(val, { Some: (n) => `some ${n}`, None: () => "none", }); } - function chainMatch(input: Option): string { - return match(input, [ + it("Executes the mapped Some branch", () => + expect(test(Some(1))).to.equal("some 1")); + it("Executes the mapped None branch", () => + expect(test(None)).to.equal("none")); + it("Throws when there is no match", () => + expect(() => + match(Some(1), { + None: () => true, // + }) + ).to.throw(/exhausted/)); +} + +function chained() { + chainedTest(); + chainedArrayTest(); + chainedObjectTest(); + chainedConditionsTest(); +} + +function chainedTest() { + function test(val: Option): string { + return match(val, [ [Some(1), "some 1"], [Some(_), "some default"], [None, "none"], ]); } - function condMatch(input: Option): string { + it("Matches the chained branches", () => { + expect(test(Some(1))).to.equal("some 1"); + expect(test(Some(2))).to.equal("some default"); + }); +} + +function chainedArrayTest() { + function test(input: Option): string { return match(input, [ - [Some(35), "35"], - [SomeIs((n) => n > 30), "gt 30"], - [SomeIs((n) => n < 20), "lt 20"], - () => "no match", + [Some([1, 2]), "1 2"], + [Some([2, _, 6]), "2 _ 6"], + () => "default", ]); } - function objMatch(input: Option<{ a: number; c?: { d: number } }>): string { + it("Deeply matches arrays within chains", () => { + expect(test(Some([1, 2, 3]))).to.equal("1 2"); + expect(test(Some([2, 4, 6]))).to.equal("2 _ 6"); + expect(test(Some([2, 3, 4]))).to.equal("default"); + }); +} + +function chainedObjectTest() { + function test(input: Option<{ a: number; c?: { d: number } }>): string { return match(input, [ [Some({ a: 1 }), "a 1"], [Some({ c: { d: 10 } }), "cd 10"], @@ -48,15 +74,39 @@ export default function option() { ]); } - function arrMatch(input: Option): string { + it("Deeply matches objects within chains", () => { + expect(test(Some({ a: 1 }))).to.equal("a 1"); + expect(test(Some({ a: 2 }))).to.equal("default"); + expect(test(Some({ a: 2, c: { d: 10 } }))).to.equal("cd 10"); + }); +} + +function chainedConditionsTest() { + function test(input: Option): string { return match(input, [ - [Some([1, 2]), "1 2"], - [Some([2, _, 6]), "2 _ 6"], - () => "default", + [Some(35), "35"], + [(n) => n.unwrapOr(0) > 30, "gt 30"], + [(n) => n.unwrapOr(20) < 20, "lt 20"], + () => "no match", ]); } - function hybridMatch(input: Option): string { + it("Matches the chained branches based on conditions", () => { + expect(test(Some(5))).to.equal("lt 20"); + expect(test(Some(25))).to.equal("no match"); + expect(test(Some(35))).to.equal("35"); + expect(test(Some(40))).to.equal("gt 30"); + expect(test(None)).to.equal("no match"); + }); +} + +function combined() { + combinedTest(); + combinedPartialTest; +} + +function combinedTest() { + function test(input: Option): string { return match(input, { Some: [ [1, "some 1"], @@ -67,7 +117,16 @@ export default function option() { }); } - function partialMatch(input: Option): string { + it("Matches chained branches within mapped branches", () => { + expect(test(Some(1))).to.equal("some 1"); + expect(test(Some(14))).to.equal("some gt 10"); + expect(test(Some(5))).to.equal("some default"); + expect(test(None)).to.equal("none"); + }); +} + +function combinedPartialTest() { + function test(input: Option): string { return match(input, { Some: [ [1, "some 1"], @@ -77,43 +136,10 @@ export default function option() { }); } - it("Executes the mapped Some branch", () => - expect(mappedMatch(Some(1))).to.equal("some 1")); - it("Executes the mapped None branch", () => - expect(mappedMatch(None)).to.equal("none")); - it("Matches the chained branches", () => { - expect(chainMatch(Some(1))).to.equal("some 1"); - expect(chainMatch(Some(2))).to.equal("some default"); - }); - it("Matches the chained branches based on conditions", () => { - expect(condMatch(Some(5))).to.equal("lt 20"); - expect(condMatch(Some(25))).to.equal("no match"); - expect(condMatch(Some(35))).to.equal("35"); - expect(condMatch(Some(40))).to.equal("gt 30"); - expect(condMatch(None)).to.equal("no match"); - }); - it("Deeply matches objects within chains", () => { - expect(objMatch(Some({ a: 1 }))).to.equal("a 1"); - expect(objMatch(Some({ a: 2 }))).to.equal("default"); - expect(objMatch(Some({ a: 2, c: { d: 10 } }))).to.equal("cd 10"); - }); - it("Deeply matches arrays within chains", () => { - expect(arrMatch(Some([1, 2, 3]))).to.equal("1 2"); - expect(arrMatch(Some([2, 4, 6]))).to.equal("2 _ 6"); - expect(arrMatch(Some([2, 3, 4]))).to.equal("default"); - }); - it("Matches chained branches within mapped branches", () => { - expect(hybridMatch(Some(1))).to.equal("some 1"); - expect(hybridMatch(Some(14))).to.equal("some gt 10"); - expect(hybridMatch(Some(5))).to.equal("some default"); - expect(hybridMatch(None)).to.equal("none"); - }); it("Falls back to the default case", () => { - expect(partialMatch(Some(1))).to.equal("some 1"); - expect(partialMatch(Some(15))).to.equal("some gt 10"); - expect(partialMatch(Some(5))).to.equal("default"); - expect(partialMatch(None)).to.equal("default"); + expect(test(Some(1))).to.equal("some 1"); + expect(test(Some(15))).to.equal("some gt 10"); + expect(test(Some(5))).to.equal("default"); + expect(test(None)).to.equal("default"); }); - it("Throws when there is no match", () => - expect(() => match(Some(1), { None: () => true })).to.throw(/exhausted/)); } diff --git a/tests/match/suite/primitives.test.ts b/tests/match/suite/primitives.test.ts index a56a87a..30d1a36 100644 --- a/tests/match/suite/primitives.test.ts +++ b/tests/match/suite/primitives.test.ts @@ -1,24 +1,16 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { match, Fn } from "../../../src"; export default function primitives() { + matchPrimitiveTest(); + returnFunctionFromFnTest(); +} + +const returnTrue = () => true; +const returnFalse = () => false; + +function matchPrimitiveTest() { const testObj = {}; - const returnTrue = () => true; - const returnFalse = () => false; function matchPrimitive(input: unknown): string { return match(input, [ @@ -33,14 +25,6 @@ export default function primitives() { ]); } - function returnFunction(input: number): () => boolean { - return match(input, [ - [1, Fn(returnTrue)], - () => returnFalse, - // - ]); - } - it("Should match a primitive and return a value", () => expect(matchPrimitive(1)).to.equal("number")); it("Should match an exact reference", () => @@ -55,14 +39,32 @@ export default function primitives() { expect(matchPrimitive(returnTrue)).to.equal("fn true"); expect(matchPrimitive(returnFalse)).to.equal("fn false"); }); - it("Should return an exact function", () => { - expect(returnFunction(1)).to.equal(returnTrue); - expect(returnFunction(2)).to.equal(returnFalse); - }); it("Should return the default value when nothing else matches", () => expect(matchPrimitive("none")).to.equal("default")); it("Should call a default function when nothing else matches", () => - expect(match(null, [() => "default"])).to.equal("default")); + expect( + match(null, [ + () => "default", // + ]) + ).to.equal("default")); it("Should throw if nothing matches and no default is present", () => - expect(() => match(2, [[1, "one"]] as any)).to.throw(/exhausted/)); + expect(() => + match(2, [ + [1 as any, "one"], // + ]) + ).to.throw(/exhausted/)); +} + +function returnFunctionFromFnTest() { + function returnFunction(input: number): () => boolean { + return match(input, [ + [1, Fn(returnTrue)], // + () => returnFalse, + ]); + } + + it("Should return the exact function from Fn", () => { + expect(returnFunction(1)).to.equal(returnTrue); + expect(returnFunction(2)).to.equal(returnFalse); + }); } diff --git a/tests/match/suite/result.test.ts b/tests/match/suite/result.test.ts index 4ec01a1..7240c2c 100644 --- a/tests/match/suite/result.test.ts +++ b/tests/match/suite/result.test.ts @@ -1,29 +1,41 @@ import { expect } from "chai"; -import { - Option, - Result, - Some, - None, - Ok, - Err, - match, - Fn, - SomeIs, - OkIs, - ErrIs, - _, - Default, -} from "../../../src"; +import { Result, Ok, Err, match, _ } from "../../../src"; export default function result() { - function mappedMatch(input: Result): string { + describe("Mapped", mapped); + describe("Chained", chained); + describe("Combined (chained within mapped)", combined); +} + +function mapped() { + function test(input: Result): string { return match(input, { Ok: (n) => `ok ${n}`, Err: (n) => `err ${n}`, }); } - function chainMatch(input: Result): string { + it("Executes the mapped Ok branch", () => + expect(test(Ok(1))).to.equal("ok 1")); + it("Executes the mapped Err branch", () => + expect(test(Err("err"))).to.equal("err err")); + it("Throws when there is no matching branch", () => + expect(() => + match(Ok(1), { + Err: () => true, // + }) + ).to.throw(/exhausted/)); +} + +function chained() { + chainedTest(); + chainedArrayTest(); + chainedObjectTest(); + chainedConditionsTest(); +} + +function chainedTest() { + function test(input: Result): string { return match(input, [ [Ok(1), "ok 1"], [Err("err"), "err err"], @@ -32,18 +44,36 @@ export default function result() { ]); } - function condMatch(input: Result): string { + it("Matches the chained branches", () => { + expect(test(Ok(1))).to.equal("ok 1"); + expect(test(Ok(2))).to.equal("ok default"); + expect(test(Err("err"))).to.equal("err err"); + expect(test(Err("nomatch"))).to.equal("err default"); + }); +} +function chainedArrayTest() { + function test(input: Result): string { return match(input, [ - [Ok(35), "ok 35"], - [OkIs((n) => n > 30), "ok gt 30"], - [OkIs((n) => n < 10), "ok lt 10"], - [Err("err"), "err err"], - [ErrIs((str) => str.startsWith("a")), "err a"], - () => "no match", + [Ok([1, 2]), "ok 1 2"], + [Ok([2, _, 6]), "ok 2 _ 6"], + [Err(["a", "b"]), "err a b"], + [Err([_, "c", "d"]), "err _ c d"], + () => "default", ]); } - function objMatch( + it("Should deeply match arrays in chains", () => { + expect(test(Ok([1, 2, 3]))).to.equal("ok 1 2"); + expect(test(Ok([2, 4, 6]))).to.equal("ok 2 _ 6"); + expect(test(Ok([2, 3, 4]))).to.equal("default"); + expect(test(Err(["a", "b", "c"]))).to.equal("err a b"); + expect(test(Err(["b", "c", "d"]))).to.equal("err _ c d"); + expect(test(Err(["c", "d", "e"]))).to.equal("default"); + }); +} + +function chainedObjectTest() { + function test( input: Result< { a: number; c?: { d: number } }, { b: number; c?: { d: number } } @@ -58,21 +88,50 @@ export default function result() { ]); } - function arrMatch(input: Result): string { + it("Should deeply match objects in chains", () => { + expect(test(Ok({ a: 1 }))).to.equal("ok a 1"); + expect(test(Ok({ a: 2, c: { d: 5 } }))).to.equal("default"); + expect(test(Ok({ a: 2, c: { d: 10 } }))).to.equal("ok cd 10"); + expect(test(Err({ b: 1 }))).to.equal("err b 1"); + expect(test(Err({ b: 2, c: { d: 10 } }))).to.equal("default"); + expect(test(Err({ b: 2, c: { d: 5 } }))).to.equal("err cd 5"); + }); +} + +function chainedConditionsTest() { + function test(input: Result): string { return match(input, [ - [Ok([1, 2]), "ok 1 2"], - [Ok([2, _, 6]), "ok 2 _ 6"], - [Err(["a", "b"]), "err a b"], - [Err([_, "c", "d"]), "err _ c d"], - () => "default", + [Ok(35), "ok 35"], + [(n) => n.unwrapOr(0) > 30, "ok gt 30"], + [(n) => n.unwrapOr(10) < 10, "ok lt 10"], + [Err("err"), "err err"], + [(str) => str.isErr() && str.unwrapErr().startsWith("a"), "err a"], + () => "no match", ]); } - function hybridMatch(input: Result): string { + it("Matches chained branches based on conditions", () => { + expect(test(Ok(5))).to.equal("ok lt 10"); + expect(test(Ok(25))).to.equal("no match"); + expect(test(Ok(35))).to.equal("ok 35"); + expect(test(Ok(40))).to.equal("ok gt 30"); + expect(test(Err("err"))).to.equal("err err"); + expect(test(Err("abc"))).to.equal("err a"); + expect(test(Err("def"))).to.equal("no match"); + }); +} + +function combined() { + combinedTest(); + combinedPartialTest; +} + +function combinedTest() { + function test(input: Result): string { return match(input, { Ok: [ - [1, "ok 1"], - [(n) => n > 10, "ok gt 10"], // + [1, "ok 1"], // + [(n) => n > 10, "ok gt 10"], () => "ok default", ], Err: [ @@ -83,7 +142,18 @@ export default function result() { }); } - function partialMatch(input: Result): string { + it("Matches chained branches within mapped branches", () => { + expect(test(Ok(1))).to.equal("ok 1"); + expect(test(Ok(15))).to.equal("ok gt 10"); + expect(test(Ok(5))).to.equal("ok default"); + expect(test(Err("err"))).to.equal("err err"); + expect(test(Err("abc"))).to.equal("err a"); + expect(test(Err("def"))).to.equal("err default"); + }); +} + +function combinedPartialTest() { + function test(input: Result): string { return match(input, { Ok: [ [1, "ok 1"], @@ -94,56 +164,11 @@ export default function result() { }); } - it("Executes the mapped Ok branch", () => - expect(mappedMatch(Ok(1))).to.equal("ok 1")); - it("Executes the mapped Err branch", () => - expect(mappedMatch(Err("err"))).to.equal("err err")); - it("Matches the chained branches", () => { - expect(chainMatch(Ok(1))).to.equal("ok 1"); - expect(chainMatch(Ok(2))).to.equal("ok default"); - expect(chainMatch(Err("err"))).to.equal("err err"); - expect(chainMatch(Err("nomatch"))).to.equal("err default"); - }); - it("Matches chained branches based on conditions", () => { - expect(condMatch(Ok(5))).to.equal("ok lt 10"); - expect(condMatch(Ok(25))).to.equal("no match"); - expect(condMatch(Ok(35))).to.equal("ok 35"); - expect(condMatch(Ok(40))).to.equal("ok gt 30"); - expect(condMatch(Err("err"))).to.equal("err err"); - expect(condMatch(Err("abc"))).to.equal("err a"); - expect(condMatch(Err("def"))).to.equal("no match"); - }); - it("Should deeply match objects in chains", () => { - expect(objMatch(Ok({ a: 1 }))).to.equal("ok a 1"); - expect(objMatch(Ok({ a: 2, c: { d: 5 } }))).to.equal("default"); - expect(objMatch(Ok({ a: 2, c: { d: 10 } }))).to.equal("ok cd 10"); - expect(objMatch(Err({ b: 1 }))).to.equal("err b 1"); - expect(objMatch(Err({ b: 2, c: { d: 10 } }))).to.equal("default"); - expect(objMatch(Err({ b: 2, c: { d: 5 } }))).to.equal("err cd 5"); - }); - it("Should deeply match arrays in chains", () => { - expect(arrMatch(Ok([1, 2, 3]))).to.equal("ok 1 2"); - expect(arrMatch(Ok([2, 4, 6]))).to.equal("ok 2 _ 6"); - expect(arrMatch(Ok([2, 3, 4]))).to.equal("default"); - expect(arrMatch(Err(["a", "b", "c"]))).to.equal("err a b"); - expect(arrMatch(Err(["b", "c", "d"]))).to.equal("err _ c d"); - expect(arrMatch(Err(["c", "d", "e"]))).to.equal("default"); - }); - it("Matches chained branches within mapped branches", () => { - expect(hybridMatch(Ok(1))).to.equal("ok 1"); - expect(hybridMatch(Ok(15))).to.equal("ok gt 10"); - expect(hybridMatch(Ok(5))).to.equal("ok default"); - expect(hybridMatch(Err("err"))).to.equal("err err"); - expect(hybridMatch(Err("abc"))).to.equal("err a"); - expect(hybridMatch(Err("def"))).to.equal("err default"); - }); it("Falls back to the default case", () => { - expect(partialMatch(Ok(1))).to.equal("ok 1"); - expect(partialMatch(Ok(15))).to.equal("ok gt 10"); - expect(partialMatch(Err("err"))).to.equal("err err"); - expect(partialMatch(Ok(5))).to.equal("default"); - expect(partialMatch(Err("nomatch"))).to.equal("default"); + expect(test(Ok(1))).to.equal("ok 1"); + expect(test(Ok(15))).to.equal("ok gt 10"); + expect(test(Err("err"))).to.equal("err err"); + expect(test(Ok(5))).to.equal("default"); + expect(test(Err("nomatch"))).to.equal("default"); }); - it("Throws when there is no matching branch", () => - expect(() => match(Ok(1), { Err: () => true })).to.throw(/exhausted/)); } diff --git a/tests/match/suite/union.test.ts b/tests/match/suite/union.test.ts new file mode 100644 index 0000000..259e602 --- /dev/null +++ b/tests/match/suite/union.test.ts @@ -0,0 +1,45 @@ +import { expect } from "chai"; +import { Option, Some, None, Result, Ok, Err, match } from "../../../src"; + +export default function union() { + describe("Mapped", mapped); + describe("Combined (chained within mapped)", combined); +} + +function mapped() { + function test(val: Option | Result): string { + return match(val, { + Some: (x) => `some ${x}`, + Ok: (x) => `ok ${x}`, + Err: (x) => `err ${x}`, + None: () => `none`, + }); + } + + it("Should match Some", () => expect(test(Some(1))).to.equal("some 1")); + it("Should match Ok", () => expect(test(Ok(1))).to.equal("ok 1")); + it("Should match Err", () => expect(test(Err(1))).to.equal("err 1")); + it("Should match None", () => expect(test(None)).to.equal("none")); +} + +function combined() { + function test(val: Option | Result): string { + return match(val, { + Some: [[1, "some 1"]], + Ok: [[1, "ok 1"]], + Err: [[1, "err 1"]], + None: () => `none`, + _: () => "default", + }); + } + + it("Should match Some", () => expect(test(Some(1))).to.equal("some 1")); + it("Should match Ok", () => expect(test(Ok(1))).to.equal("ok 1")); + it("Should match Err", () => expect(test(Err(1))).to.equal("err 1")); + it("Should match None", () => expect(test(None)).to.equal("none")); + it("Should return the default otherwise", () => { + it("Should match Some", () => expect(test(Some(2))).to.equal("default")); + it("Should match Ok", () => expect(test(Ok(2))).to.equal("default")); + it("Should match Err", () => expect(test(Err(2))).to.equal("default")); + }); +} diff --git a/tests/option/index.ts b/tests/option/index.ts index 0ab946d..d425309 100644 --- a/tests/option/index.ts +++ b/tests/option/index.ts @@ -1,17 +1,19 @@ import some from "./suite/some.test"; import none from "./suite/none.test"; import methods from "./suite/methods.test"; +import iter from "./suite/iter.test"; +import convert from "./suite/convert.test"; import safe from "./suite/safe.test"; import all from "./suite/all.test"; import any from "./suite/any.test"; -import guard from "./suite/guard.test"; export default function option() { describe("Some", some); describe("None", none); describe("Methods", methods); + describe("Iterable", iter); + describe("Convert", convert); describe("Option.safe", safe); describe("Option.all", all); describe("Option.any", any); - describe("OptionGuard", guard); } diff --git a/tests/option/suite/convert.test.ts b/tests/option/suite/convert.test.ts new file mode 100644 index 0000000..ab9d0e5 --- /dev/null +++ b/tests/option/suite/convert.test.ts @@ -0,0 +1,67 @@ +import { expect } from "chai"; +import { Option } from "../../../src"; + +export default function convert() { + describe("from", from); + describe("nonNull", nonNull); + describe("qty", qty); +} + +function from() { + it("Should cast falsey values to None", () => { + [false, null, undefined, NaN, 0, -0, 0n, ""].forEach( + (falsey) => expect(Option.from(falsey).isNone()).to.be.true + ); + }); + + it("Should cast Error (and subclasses) to None", () => { + class TestError extends Error {} + expect(Option.from(new Error("test")).isNone()).to.be.true; + expect(Option.from(new TestError("test")).isNone()).to.be.true; + }); + + it("Should cast Invalid Date to None", () => + expect(Option.from(new Date("never")).isNone()).to.be.true); + + it("Should cast other values to Some", () => { + expect(Option.from("truthy").unwrap()).to.equal("truthy"); + }); + + it("Should be aliased by Option", () => { + expect(Option(0).isNone()).to.be.true; + expect(Option(1).unwrap()).to.equal(1); + }); +} + +function nonNull() { + it("Should cast undefined, null and NaN to None", () => { + expect(Option.nonNull(null).isNone()).to.be.true; + expect(Option.nonNull(undefined).isNone()).to.be.true; + expect(Option.nonNull(NaN).isNone()).to.be.true; + }); + + it("Should cast other values to Some", () => { + [false, 0, -0, 0n, "", new Date("never"), new Error("never")].forEach( + (val) => expect(Option.nonNull(val).unwrap()).to.equal(val) + ); + }); +} + +function qty() { + it("Should cast numbers >= 0 to Some", () => { + expect(Option.qty(0).unwrap()).to.equal(0); + expect(Option.qty(1).unwrap()).to.equal(1); + }); + + it("Should cast numbers < 0 to None", () => + expect(Option.qty(-1).isNone()).to.be.true); + + it("Should cast non-numbers to None", () => + expect(Option.qty("test" as any).isNone()).to.be.true); + + it("Should cast NaN, Infinity and -Infinity to None", () => { + expect(Option.qty(NaN).isNone()).to.be.true; + expect(Option.qty(Infinity).isNone()).to.be.true; + expect(Option.qty(-Infinity).isNone()).to.be.true; + }); +} diff --git a/tests/option/suite/guard.test.ts b/tests/option/suite/guard.test.ts deleted file mode 100644 index 8054bbf..0000000 --- a/tests/option/suite/guard.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect } from "chai"; -import { Option, Some, None } from "../../../src"; - -export default function guard() { - const fn = Option((guard, val: Option) => { - const num = guard(val); - if (num === 0) { - return None; - } else if (num === 1) { - throw new Error("test"); - } else if (num === 2) { - try { - guard(None); - } catch (err) { - guard.bubble(err); - return Some(6); - } - } else if (num === 3) { - try { - throw new Error("test_bubble_err"); - } catch (err) { - guard.bubble(err); - throw err; - } - } - return Some(num + 1); - }); - - it("Returns None when assertion fails", () => - expect(fn(None)).to.equal(None)); - it("Returns None", () => expect(fn(Some(0))).to.equal(None)); - it("Propogates thrown errors", () => - expect(() => fn(Some(1))).to.throw("test")); - it("Guard bubbles caught None", () => - expect(fn(Some(2)).isNone()).to.be.true); - it("Guard does not bubble caught error", () => - expect(() => fn(Some(3))).to.throw("test_bubble_err")); - it("Returns Some", () => expect(fn(Some(4)).unwrap()).to.equal(5)); -} diff --git a/tests/option/suite/iter.test.ts b/tests/option/suite/iter.test.ts new file mode 100644 index 0000000..2688b5e --- /dev/null +++ b/tests/option/suite/iter.test.ts @@ -0,0 +1,29 @@ +import { expect } from "chai"; +import { Option, Some, None, Ok } from "../../../src"; + +export default function iter() { + it("Should create an iterator from Some", () => { + const iter = Some([1, 2])[Symbol.iterator](); + expect(iter.next()).to.eql({ value: 1, done: false }); + expect(iter.next()).to.eql({ value: 2, done: false }); + expect(iter.next()).to.eql({ value: undefined, done: true }); + }); + + it("Should create an empty iterator from None", () => { + const iter = (None as Option)[Symbol.iterator](); + expect(iter.next()).to.eql({ value: undefined, done: true }); + }); + + it("Should create an iterator from nested monads", () => { + const iter = Some(Ok(Some([1, 2])))[Symbol.iterator](); + expect(iter.next()).to.eql({ value: 1, done: false }); + expect(iter.next()).to.eql({ value: 2, done: false }); + expect(iter.next()).to.eql({ value: undefined, done: true }); + + const noneIter = Some(Ok(None as Option))[Symbol.iterator](); + expect(noneIter.next()).to.eql({ value: undefined, done: true }); + }); + + it("Should throw if the contained value is not iterable", () => + expect(() => Some(1)[Symbol.iterator]()).to.throw(/not a function/)); +} diff --git a/tests/option/suite/methods.test.ts b/tests/option/suite/methods.test.ts index 699295f..87316b9 100644 --- a/tests/option/suite/methods.test.ts +++ b/tests/option/suite/methods.test.ts @@ -6,27 +6,18 @@ function AsOpt(val: Option): Option { } export default function methods() { - it("is", () => { - expect(Some(1).is(Some(2))).to.be.true; - expect(AsOpt(None).is(Some(1))).to.be.false; - expect(Some(1).is(None)).to.be.false; - expect(None.is(None)).to.be.true; + it("into", () => { + expect(Some(1).into()).to.equal(1); + expect(None.into()).to.equal(undefined); + expect(None.into(false)).to.equal(false); + expect(None.into(null)).to.equal(null); }); - it("eq", () => { - expect(Some(1).eq(Some(1))).to.be.true; - expect(None.eq(None)).to.be.true; - expect(Some(1).eq(None)).to.be.false; - expect(AsOpt(None).eq(Some(1))).to.be.false; - expect(Some(1).eq(Some(2))).to.be.false; - }); - - it("neq", () => { - expect(Some(1).neq(Some(1))).to.be.false; - expect(None.neq(None)).to.be.false; - expect(Some(1).neq(None)).to.be.true; - expect(AsOpt(None).neq(Some(1))).to.be.true; - expect(Some(1).neq(Some(2))).to.be.true; + it("isLike", () => { + expect(Some(1).isLike(Some(2))).to.be.true; + expect(AsOpt(None).isLike(Some(1))).to.be.false; + expect(Some(1).isLike(None)).to.be.false; + expect(None.isLike(None)).to.be.true; }); it("isSome", () => { @@ -39,6 +30,13 @@ export default function methods() { expect(None.isNone()).to.be.true; }); + it("filter", () => { + const lessThan5 = (x: number) => x < 5; + expect(Some(1).filter(lessThan5).unwrap()).to.equal(1); + expect(Some(10).filter(lessThan5).isNone()).to.be.true; + expect(None.filter(lessThan5).isNone()).to.be.true; + }); + it("expect", () => { expect(Some(1).expect("test")).to.equal(1); expect(() => None.expect("test")).to.throw("test"); diff --git a/tests/option/suite/safe.test.ts b/tests/option/suite/safe.test.ts index 257be64..2ad7262 100644 --- a/tests/option/suite/safe.test.ts +++ b/tests/option/suite/safe.test.ts @@ -1,8 +1,13 @@ import { expect } from "chai"; -import { Option, Some, None } from "../../../src"; +import { Option } from "../../../src"; export default function safe() { - const fn = (throws: boolean) => { + functionTest(); + promiseTest(); +} + +function functionTest() { + const test = (throws: boolean) => { if (throws) { throw new Error("test_err"); } else { @@ -11,12 +16,14 @@ export default function safe() { }; it("Should be Some when the provided function returns", () => - expect(Option.safe(fn, false).unwrap()).to.equal("testing")); + expect(Option.safe(test, false).unwrap()).to.equal("testing")); it("Should be None when the provided function throws", () => - expect(Option.safe(fn, true).isNone()).to.be.true); + expect(Option.safe(test, true).isNone()).to.be.true); +} - const fnAsync = async (throws: boolean) => { +function promiseTest() { + const test = async (throws: boolean) => { if (throws) { throw new Error("test_err"); } else { @@ -25,8 +32,8 @@ export default function safe() { }; it("Should be Ok when the provided Promise resolves", async () => - expect((await Option.safe(fnAsync(false))).unwrap()).to.equal("testing")); + expect((await Option.safe(test(false))).unwrap()).to.equal("testing")); it("Should be Err when the provided Promise rejects", async () => - expect((await Option.safe(fnAsync(true))).isNone()).to.be.true); + expect((await Option.safe(test(true))).isNone()).to.be.true); } diff --git a/tests/result/index.ts b/tests/result/index.ts index c7acca8..8800ca1 100644 --- a/tests/result/index.ts +++ b/tests/result/index.ts @@ -1,17 +1,19 @@ import some from "./suite/ok.test"; import none from "./suite/err.test"; import methods from "./suite/methods.test"; +import iter from "./suite/iter.test"; +import convert from "./suite/convert.test"; import safe from "./suite/safe.test"; import all from "./suite/all.test"; import any from "./suite/any.test"; -import guard from "./suite/guard.test"; export default function option() { describe("Ok", some); describe("Err", none); describe("Methods", methods); + describe("Iterable", iter); + describe("Convert", convert); describe("Result.safe", safe); describe("Result.all", all); describe("Result.any", any); - describe("ResultGuard", guard); } diff --git a/tests/result/suite/convert.test.ts b/tests/result/suite/convert.test.ts new file mode 100644 index 0000000..4398ac3 --- /dev/null +++ b/tests/result/suite/convert.test.ts @@ -0,0 +1,72 @@ +import { expect } from "chai"; +import { Result } from "../../../src"; + +export default function convert() { + describe("from", from); + describe("nonNull", nonNull); + describe("qty", qty); +} + +function from() { + it("Should cast falsey values to Err", () => { + expect(Result.from(NaN).unwrapErr()).to.be.null; + [false, null, undefined, 0, -0, 0n, ""].forEach((falsey) => + expect(Result.from(falsey).unwrapErr()).to.equal(null) + ); + }); + + it("Should cast Invalid Date to Err", () => + expect(Result.from(new Date("never")).unwrapErr()).to.be.null); + + it("Should cast Error (and subclasses) to Err", () => { + class TestError extends Error {} + const errInstance = new Error("test"); + const testErrInstance = new TestError("test"); + expect(Result.from(errInstance).unwrapErr()).to.equal(errInstance); + expect(Result.from(testErrInstance).unwrapErr()).to.equal( + testErrInstance + ); + }); + + it("Should cast other values to Ok", () => { + expect(Result.from("truthy").unwrap()).to.equal("truthy"); + }); + + it("Should be aliased by Result", () => { + expect(Result(0).unwrapErr()).to.equal(null); + expect(Result(1).unwrap()).to.equal(1); + }); +} + +function nonNull() { + it("Should cast undefined, null and NaN to Err", () => { + expect(Result.nonNull(null).unwrapErr()).to.equal(null); + expect(Result.nonNull(undefined).unwrapErr()).to.equal(null); + expect(Result.nonNull(NaN).unwrapErr()).to.equal(null); + }); + + it("Should cast other values to Ok", () => { + [false, 0, -0, 0n, "", new Date("never"), new Error("never")].forEach( + (val) => expect(Result.nonNull(val).unwrap()).to.equal(val) + ); + }); +} + +function qty() { + it("Should cast numbers >= 0 to Ok", () => { + expect(Result.qty(0).unwrap()).to.equal(0); + expect(Result.qty(1).unwrap()).to.equal(1); + }); + + it("Should cast numbers < 0 to Err", () => + expect(Result.qty(-1).unwrapErr()).to.be.null); + + it("Should cast non-numbers to Err", () => + expect(Result.qty("test" as any).unwrapErr()).to.be.null); + + it("Should cast NaN, Infinity and -Infinity to Err", () => { + expect(Result.qty(NaN).unwrapErr()).to.be.null; + expect(Result.qty(Infinity).unwrapErr()).to.be.null; + expect(Result.qty(-Infinity).unwrapErr()).to.be.null; + }); +} diff --git a/tests/result/suite/guard.test.ts b/tests/result/suite/guard.test.ts deleted file mode 100644 index dbf9264..0000000 --- a/tests/result/suite/guard.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect } from "chai"; -import { Result, Guard, Ok, Err } from "../../../src"; - -export default function guard() { - const fn = Result((guard: Guard, val: Result) => { - const num = guard(val); - if (num === 0) { - return Err("test_err"); - } else if (num === 1) { - throw new Error("test_throw"); - } else if (num === 2) { - try { - guard(Err("test_bubble")); - } catch (err) { - guard.bubble(err); - return Ok(5); - } - } else if (num === 3) { - try { - throw new Error("test_bubble_err"); - } catch (err) { - guard.bubble(err); - throw err; - } - } - return Ok(num + 1); - }); - - it("Returns Err when assertion fails", () => - expect(fn(Err("test_outer_err")).unwrapErr()).to.equal("test_outer_err")); - it("Returns Err", () => expect(fn(Ok(0)).unwrapErr()).to.equal("test_err")); - it("Bubbles thrown errors", () => - expect(() => fn(Ok(1))).to.throw("test_throw")); - it("Guard can bubble caught Err", () => - expect(fn(Ok(2)).unwrapErr()).to.equal("test_bubble")); - it("Guard does not bubble caught error", () => - expect(() => fn(Ok(3))).to.throw("test_bubble_err")); - it("Returns Ok", () => expect(fn(Ok(4)).unwrap()).to.equal(5)); -} diff --git a/tests/result/suite/iter.test.ts b/tests/result/suite/iter.test.ts new file mode 100644 index 0000000..c9eb473 --- /dev/null +++ b/tests/result/suite/iter.test.ts @@ -0,0 +1,31 @@ +import { expect } from "chai"; +import { Result, Ok, Err, Some } from "../../../src"; + +export default function iter() { + it("Should create an iterator from Ok", () => { + const iter = Ok([1, 2])[Symbol.iterator](); + expect(iter.next()).to.eql({ value: 1, done: false }); + expect(iter.next()).to.eql({ value: 2, done: false }); + expect(iter.next()).to.eql({ value: undefined, done: true }); + }); + + it("Should create an empty iterator from Err", () => { + const err = Err([1, 2]) as Result; + const iter = err[Symbol.iterator](); + expect(iter.next()).to.eql({ value: undefined, done: true }); + }); + + it("Should create an iterator from nested monads", () => { + const iter = Ok(Some(Ok([1, 2])))[Symbol.iterator](); + expect(iter.next()).to.eql({ value: 1, done: false }); + expect(iter.next()).to.eql({ value: 2, done: false }); + expect(iter.next()).to.eql({ value: undefined, done: true }); + + const err = Err([1, 2]) as Result; + const errIter = Ok(Some(err))[Symbol.iterator](); + expect(errIter.next()).to.eql({ value: undefined, done: true }); + }); + + it("Should throw if the contained value is not iterable", () => + expect(() => Ok(1)[Symbol.iterator]()).to.throw(/not a function/)); +} diff --git a/tests/result/suite/methods.test.ts b/tests/result/suite/methods.test.ts index f934aef..c28b456 100644 --- a/tests/result/suite/methods.test.ts +++ b/tests/result/suite/methods.test.ts @@ -1,26 +1,23 @@ import { expect } from "chai"; -import { Result, Ok, Err } from "../../../src"; +import { Result, Ok, Err, Some } from "../../../src"; -export default function methods() { - it("is", () => { - expect(Ok(1).is(Ok(2))).to.be.true; - expect(Err(1).is(Ok(1))).to.be.false; - expect(Ok(1).is(Err(1))).to.be.false; - expect(Err(1).is(Err(2))).to.be.true; - }); +function AsRes(val: unknown): Result { + return val as Result; +} - it("eq", () => { - expect(Ok(1).eq(Ok(1))).to.be.true; - expect(Err(1).eq(Err(1))).to.be.true; - expect(Ok(1).eq(Err(1))).to.be.false; - expect(Ok(1).eq(Ok(2))).to.be.false; +export default function methods() { + it("into", () => { + expect(Ok(1).into()).to.equal(1); + expect(Err(1).into()).to.equal(undefined); + expect(Err(1).into(false)).to.equal(false); + expect(Err(1).into(null)).to.equal(null); }); - it("neq", () => { - expect(Ok(1).neq(Ok(1))).to.be.false; - expect(Err(1).neq(Err(1))).to.be.false; - expect(Ok(1).neq(Err(1))).to.be.true; - expect(Ok(1).neq(Ok(2))).to.be.true; + it("isLike", () => { + expect(Ok(1).isLike(Ok(2))).to.be.true; + expect(Err(1).isLike(Ok(1))).to.be.false; + expect(Ok(1).isLike(Err(1))).to.be.false; + expect(Err(1).isLike(Err(2))).to.be.true; }); it("isOk", () => { @@ -28,11 +25,19 @@ export default function methods() { expect(Err(1).isOk()).to.be.false; }); - it("isNone", () => { + it("isErr", () => { expect(Ok(1).isErr()).to.be.false; expect(Err(1).isErr()).to.be.true; }); + it("filter", () => { + const lessThan5 = (x: number) => x < 5; + expect(Ok(1).filter(lessThan5).isLike(Some(1))).to.be.true; + expect(Ok(1).filter(lessThan5).unwrap()).to.equal(1); + expect(Ok(10).filter(lessThan5).isNone()).to.be.true; + expect(Err(1).filter(lessThan5).isNone()).to.be.true; + }); + it("expect", () => { expect(Ok(1).expect("test")).to.equal(1); expect(() => Err(1).expect("test")).to.throw("test"); @@ -55,12 +60,12 @@ export default function methods() { it("unwrapOr", () => { expect(Ok(1).unwrapOr(2)).to.equal(1); - expect(Err(1).unwrapOr(2)).to.equal(2); + expect(AsRes(Err(1)).unwrapOr(2)).to.equal(2); }); it("unwrapOrElse", () => { expect(Ok(1).unwrapOrElse(() => 2)).to.equal(1); - expect(Err(1).unwrapOrElse(() => 2)).to.equal(2); + expect(AsRes(Err(1)).unwrapOrElse(() => 2)).to.equal(2); }); it("unwrapUnchecked", () => { @@ -70,7 +75,7 @@ export default function methods() { it("or", () => { expect(Ok(1).or(Ok(2)).unwrap()).to.equal(1); - expect(Err(1).or(Ok(2)).unwrap()).to.equal(2); + expect(AsRes(Err(1)).or(Ok(2)).unwrap()).to.equal(2); }); it("orElse", () => { @@ -87,14 +92,14 @@ export default function methods() { }); it("and", () => { - expect(Ok(1).and(Err(2)).isErr()).to.be.true; + expect(AsRes(Ok(1)).and(Err(2)).isErr()).to.be.true; expect(Err(1).and(Ok(2)).isErr()).to.be.true; expect(Ok(1).and(Ok("two")).unwrap()).to.equal("two"); }); it("andThen", () => { expect( - Ok(1) + AsRes(Ok(1)) .andThen(() => Err(1)) .isErr() ).to.be.true; diff --git a/tests/result/suite/safe.test.ts b/tests/result/suite/safe.test.ts index ef21f99..4b81f5f 100644 --- a/tests/result/suite/safe.test.ts +++ b/tests/result/suite/safe.test.ts @@ -2,7 +2,12 @@ import { expect } from "chai"; import { Result } from "../../../src"; export default function safe() { - const fn = (throws: any) => { + describe("Function", functionTest); + describe("Promise", promiseTest); +} + +function functionTest() { + const test = (throws: any) => { if (throws) { throw throws; } else { @@ -11,19 +16,21 @@ export default function safe() { }; it("Should be Ok when the provided function returns", () => - expect(Result.safe(fn, false).unwrap()).to.equal("testing")); + expect(Result.safe(test, false).unwrap()).to.equal("testing")); it("Should be Err when the provided function throws", () => - expect(Result.safe(fn, new Error("test_err")).unwrapErr()) + expect(Result.safe(test, new Error("test_err")).unwrapErr()) .to.be.instanceof(Error) .with.property("message", "test_err")); it("Should convert thrown non-errors into Error instances", () => - expect(Result.safe(fn, "test_str").unwrapErr()) + expect(Result.safe(test, "test_str").unwrapErr()) .to.be.instanceof(Error) .with.property("message", "test_str")); +} - const fnAsync = async (throws: any) => { +function promiseTest() { + const test = async (throws: any) => { if (throws) { throw throws; } else { @@ -32,15 +39,15 @@ export default function safe() { }; it("Should be Ok when the provided Promise resolves", async () => - expect((await Result.safe(fnAsync(false))).unwrap()).to.equal("testing")); + expect((await Result.safe(test(false))).unwrap()).to.equal("testing")); it("Should be Err when the provided Promise rejects", async () => - expect((await Result.safe(fnAsync(new Error("test_err")))).unwrapErr()) + expect((await Result.safe(test(new Error("test_err")))).unwrapErr()) .to.be.instanceof(Error) .with.property("message", "test_err")); it("Should convert rejected non-errors into Error instances", async () => - expect((await Result.safe(fnAsync("test_str"))).unwrapErr()) + expect((await Result.safe(test("test_str"))).unwrapErr()) .to.be.instanceof(Error) .with.property("message", "test_str")); } diff --git a/tsconfig.json b/tsconfig.json index 6e5ab88..8bc7fdd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "include": ["src"], "exclude": [], "compilerOptions": { - "target": "es2019", + "target": "es2021", "module": "commonjs", "outDir": "dist", "strict": true,