├── .github └── FUNDING.yml ├── .gitignore ├── .node-version ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.js ├── mappers.js ├── matchers ├── index.js ├── matchAllOf.spec.js ├── matchArray.spec.js ├── matchObject.spec.js ├── matchOneOf.spec.js ├── matchRegExp.spec.js ├── matchString.spec.js ├── test-utils.js └── test-utils.spec.js ├── package-lock.json ├── package.json ├── samples.spec.js ├── tc39-proposal-pattern-matching ├── README.md ├── adventure-command.spec.js ├── array-length.spec.js ├── array-pattern-caching.spec.js ├── ascii-ci.spec.js ├── async-match.spec.js ├── built-in-custom-matchers.spec.js ├── chaining-guards.spec.js ├── comparison.jsx ├── conditional-jsx.spec.js ├── custom-matcher-option.spec.js ├── diff.png ├── fetch-json-response.spec.js ├── index.js ├── nil-pattern.spec.js ├── object-pattern-caching.spec.js ├── regexp-groups.spec.js ├── res-handler.spec.js ├── sample.js ├── todo-reducer.spec.js └── with-chaining-regexp.spec.js ├── time-jump-iterator.js └── time-jump-iterator.spec.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: pyrolistical 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.14.0 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .history/ 2 | node_modules/ 3 | dist/ 4 | coverage/ 5 | .github/ 6 | **/*.spec.js 7 | **/test-utils.js 8 | tc39-proposal-pattern-matching/sample.js 9 | tc39-proposal-pattern-matching/comparison.jsx 10 | .prettier* 11 | .vscode/ 12 | .node-version 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/concept-not-found/patcom/b0ce82e93e95d02123e7aff2bb1b7a1c4d8fd488/.prettierignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.1.0 2 | 3 | ### Features 4 | 5 | - added new `asInternalIterator` API to be able to pass existing iterators directly to `Matcher`s 6 | - upgraded `not` matcher to be an `InteratorMatcher` which allows it to interoperate with other `Matcher`s 7 | 8 | # 1.0.0 9 | 10 | ### Breaking changes 11 | 12 | - unified value matcher as an iterator matcher 13 | - aligned expected into `matchArray` with values. this means `rest`, `some`, `group` are all arrays in the value. `maybe` will be present as `undefined` on matched array value 14 | - grouped `rest` values on matchObject 15 | - changed `asMatcher` match `undefined` instead of matching `any` when given an `undefined` 16 | - passing in `undefined` into equals now requires a match to `undefined`, where as previously it acted as `any` 17 | - normalized all results fields to be singular result 18 | - fixed bug where `asMatcher` was returning wrong matcher for boolean, number and string 19 | - fixed bug for `matchString` where did not handle `new String`s correctly for either the expected nor the value 20 | - fixed bug where a string passed into `matchArray` was not unmatched 21 | 22 | ### Features 23 | 24 | - rewrote `cachedGenerator` to be `TimeJumpIterator` with a `now` and `jump` API. `TimeJumpIterator` is now part of the published API 25 | - added new matchers, `maybe`, `some`, `group` 26 | - relicense to MIT 27 | 28 | # 0.1.3 29 | 30 | initial release 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Ronald Chen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # patcom 2 | 3 | `patcom` is a pattern-matching JavaScript library. Build pattern matchers from simpler, smaller matchers. 4 | 5 | > Pattern-matching uses declarative programming. The code matches the shape of the data. 6 | 7 | ```sh 8 | npm install --save patcom 9 | ``` 10 | 11 | ## Simple example 12 | 13 | Let's say we have objects that represent a `Student` or a `Teacher`. 14 | 15 | 16 | ```ts 17 | type Student = { 18 | role: 'student' 19 | } 20 | 21 | type Teacher = { 22 | role: 'teacher', 23 | surname: string 24 | } 25 | ``` 26 | 27 | Using `patcom`, we can match a `person` by their `role` to form a greeting. 28 | 29 | 30 | ```js 31 | import {match, when, otherwise, defined} from 'patcom' 32 | 33 | function greet(person) { 34 | return match (person) ( 35 | when ( 36 | { role: 'student' }, 37 | () => 'Hello fellow student.' 38 | ), 39 | 40 | when ( 41 | { role: 'teacher', surname: defined }, 42 | ({ surname }) => `Good morning ${surname} sensei.` 43 | ), 44 | 45 | otherwise ( 46 | () => 'STRANGER DANGER' 47 | ) 48 | ) 49 | } 50 | 51 | 52 | greet({ role: 'student' }) ≡ 'Hello fellow student.' 53 | greet({ role: 'teacher', surname: 'Wong' }) ≡ 'Good morning Wong sensei.' 54 | greet({ role: 'creeper' }) ≡ 'STRANGER DANGER' 55 | ``` 56 | 57 |
58 | What is match doing? 59 | 60 | [`match`](#match) finds the first [`when`](#when) clause that matches, then the [`Matched`](#core-concept) object is transformed into the greeting. If none of the `when` clauses match, the [`otherwise`](#otherwise) clause always matches. 61 | 62 |
63 | 64 | ## More expressive than `switch` 65 | 66 | Pattern match over whole objects and not just single fields. 67 | 68 | ### Imperative `switch` & `if` 😔 69 | 70 | Oh noes, a [Pyramid of doom]() 71 | 72 | 73 | ``` 74 | switch (person.role) { 75 | case 'student': 76 | if (person.grade > 90) { 77 | return 'Gold star' 78 | } else if (person.grade > 60) { 79 | return 'Keep trying' 80 | } else { 81 | return 'See me after class' 82 | } 83 | default: 84 | throw new Exception(`expected student, but got ${person}`) 85 | } 86 | ``` 87 | 88 | ### Declarative `match` 🙂 89 | 90 | Flatten Pyramid to linear cases. 91 | 92 | 93 | ```js 94 | return match (person) ( 95 | when ( 96 | { role: 'student', grade: greaterThan(90) }, 97 | () => 'Gold star' 98 | ), 99 | 100 | when ( 101 | { role: 'student', grade: greaterThan(60) }, 102 | () => 'Keep trying' 103 | ), 104 | 105 | when ( 106 | { role: 'student', grade: defined }, 107 | () => 'See me after class' 108 | ), 109 | 110 | otherwise ( 111 | (person) => throw new Exception(`expected student, but got ${person}`) 112 | ) 113 | ) 114 | ``` 115 | 116 |
117 | What is greaterThan? 118 | 119 | [`greaterThan`](#greaterthan) is a [`Matcher`](#core-concept) provided by `patcom`. `greaterThan(90)` means "match a number greater than 90". 120 | 121 |
122 | 123 | ## Match `Array`, `String`, `RegExp` and more 124 | 125 | ### Arrays 126 | 127 | 128 | ```js 129 | match (list) ( 130 | when ( 131 | [], 132 | () => 'empty list' 133 | ), 134 | 135 | when ( 136 | [defined], 137 | ([head]) => `single item ${head}` 138 | ), 139 | 140 | when ( 141 | [defined, rest], 142 | ([head, tail]) => `multiple items` 143 | ) 144 | ) 145 | ``` 146 | 147 |
148 | What is rest? 149 | 150 | [`rest`](#rest) is an [`IteratorMatcher`](#core-concept) used within array and object patterns. Array and objects are complete matches, and the `rest` pattern consumes all remaining values. 151 | 152 |
153 | 154 | ### `String` & `RegExp` 155 | 156 | 157 | ```js 158 | match (command) ( 159 | when ( 160 | 'sit', 161 | () => sit() 162 | ), 163 | 164 | // matchedRegExp is the RegExp match result 165 | when ( 166 | /^move (\d) spaces$/, 167 | (value, { matchedRegExp: [, distance] }) => move(distance) 168 | ), 169 | 170 | // ...which means matchedRegExp has the named groups 171 | when ( 172 | /^eat (?\w+)$/, 173 | (value, { matchedRegExp: { groups: { food } } }) => eat(food) 174 | ) 175 | ) 176 | ``` 177 | 178 | ### `Number`, `BigInt` & `Boolean` 179 | 180 | 181 | ```js 182 | match (value) ( 183 | when ( 184 | 69, 185 | () => 'nice' 186 | ), 187 | 188 | when ( 189 | 69n, 190 | () => 'big nice' 191 | ), 192 | 193 | when ( 194 | true, 195 | () => 'not nice' 196 | ) 197 | ) 198 | ``` 199 | 200 | ## Match complex data structures 201 | 202 | 203 | ```js 204 | match (complex) ( 205 | when ( 206 | { schedule: [{ class: 'history', rest }, rest] }, 207 | () => 'history first thing on schedule? buy coffee' 208 | ), 209 | 210 | when ( 211 | { schedule: [{ professor: oneOf('Ko', 'Smith'), rest }, rest] }, 212 | ({ schedule: [{ professor }] }) => `Professor ${professor} teaching? bring voice recorder` 213 | ) 214 | ) 215 | ``` 216 | 217 | ### Matchers are extractable 218 | 219 | From the previous example, complex patterns can be broken down into simpler reusable matchers. 220 | 221 | 222 | ```js 223 | const fastSpeakers = oneOf('Ko', 'Smith') 224 | 225 | match (complex) ( 226 | when ( 227 | { schedule: [{ class: 'history', rest }, rest] }, 228 | () => 'history first thing on schedule? buy coffee' 229 | ), 230 | 231 | when ( 232 | { schedule: [{ professor: fastSpeakers, rest }, rest] }, 233 | ({ schedule: [{ professor }] }) => `Professor ${professor} teaching? bring voice recorder` 234 | ) 235 | ) 236 | ``` 237 | 238 | ## Custom matchers 239 | 240 | Define custom matchers with any logic. [`ValueMatcher`](#core-concept) is a helper function to define custom matchers. It wraps a function that takes in a `value` and returns a [`Result`](#core-concept). Either the `value` becomes [`Matched`](#core-concept) or is [`Unmatched`](#core-concept). 241 | 242 | 243 | ```js 244 | const matchDuck = ValueMatcher((value) => { 245 | if (value.type === 'duck') { 246 | return { 247 | matched: true, 248 | value 249 | } 250 | } 251 | return { 252 | matched: false 253 | } 254 | }) 255 | 256 | ... 257 | 258 | function speak(animal) { 259 | return match (animal) ( 260 | when ( 261 | matchDuck, 262 | () => 'quack' 263 | ), 264 | 265 | when ( 266 | matchDragon, 267 | () => 'rawr' 268 | ) 269 | ) 270 | ) 271 | ``` 272 | 273 | All the examples thus far have been using [`match`](#match), but `match` itself isn't a matcher. In order to use `speak` in another pattern, we use [`oneOf`](#oneof) instead. 274 | 275 | 276 | ```js 277 | const speakMatcher = oneOf ( 278 | when ( 279 | matchDuck, 280 | () => 'quack' 281 | ), 282 | 283 | when ( 284 | matchDragon, 285 | () => 'rawr' 286 | ) 287 | ) 288 | ``` 289 | 290 | Now upon unrecognized animals, whereas `speak` previously returned `undefined`, `speakMatcher` returns `{ matched: false }`. This allows us to combine `speakMatcher` with other patterns. 291 | 292 | 293 | ```js 294 | match (animal) ( 295 | when ( 296 | speakMatcher, 297 | (sound) => `the ${animal.type} goes ${sound}` 298 | ), 299 | 300 | otherwise( 301 | () => `the ${animal.type} remains silent` 302 | ) 303 | ) 304 | ``` 305 | 306 | Everything except for `match` is actually a [`Matcher`](#core-concept), including `when` and `otherwise`. Primitive value and data types are automatically converted to a corresponding matcher. 307 | 308 | 309 | ```js 310 | when ({ role: 'student' }, ...) ≡ 311 | when (matchObject({ role: 'student' }), ...) 312 | 313 | when (['alice'], ...) ≡ 314 | when (matchArray(['alice']), ...) 315 | 316 | when ('sit', ...) ≡ 317 | when (matchString('sit'), ...) 318 | 319 | when (/^move (\d) spaces$/, ...) ≡ 320 | when (matchRegExp(/^move (\d) spaces$/), ...) 321 | 322 | when (69, ...) ≡ 323 | when (matchNumber(69), ...) 324 | 325 | when (69n, ...) ≡ 326 | when (matchBigInt(69n), ...) 327 | 328 | when (true, ...) ≡ 329 | when (matchBoolean(true), ...) 330 | ``` 331 | 332 | Even the complex patterns are composed of simpler matchers. 333 | 334 | ### Primitives 335 | 336 | 337 | ```js 338 | when ( 339 | { 340 | schedule: [ 341 | { class: 'history', rest }, 342 | rest 343 | ] 344 | }, 345 | ... 346 | ) 347 | ``` 348 | 349 | ### Equivalent explict matchers 350 | 351 | 352 | ```js 353 | when ( 354 | matchObject({ 355 | schedule: matchArray([ 356 | matchObject({ class: matchString('history'), rest }), 357 | rest 358 | ]) 359 | }), 360 | ... 361 | ) 362 | ``` 363 | 364 | ## Core concept 365 | 366 | At the heart of `patcom`, everything is built around a single concept, the `Matcher`. The `Matcher` takes any `value` and returns a `Result`, which is either `Matched` or `Unmatched`. Internally, the `Matcher` consumes a `TimeJumpIterator` to allow for lookahead. 367 | 368 | Custom matchers are easily implemented using the `ValueMatcher` helper function. It removes the need to handle the internals of `TimeJumpIterator`. 369 | 370 | 371 | ```ts 372 | type Matcher = (value: TimeJumpIterator | any) => Result 373 | 374 | function ValueMatcher(fn: (value: any) => Result): Matcher 375 | 376 | type Result = Matched | Unmatched 377 | 378 | type Matched = { 379 | matched: true, 380 | value: T 381 | } 382 | 383 | type Unmatched = { 384 | matched: false 385 | } 386 | ``` 387 | 388 | For more advanced use cases, the `IteratorMatcher` helper function is used to create `Matcher`s that directly handle the internals of `TimeJumpIterator` but do not need to be concerned with a plain `value` being passed in. 389 | 390 | The `TimeJumpIterator` works like a normal `Iterator`, except it can jump back to a previous state. This is useful for `Matcher`s that require lookahead. For example, the [`maybe`](#maybe) matcher would remember the starting position with `const start = iterator.now`, look ahead to see if there is a match, and if it fails, jumps the iterator back using `iterator.jump(start)`. This prevents the iterator from being consumed. If the iterator is consumed during the lookahead and left untouched on unmatched, subsequent matchers will fail to match as they would never see the values that were consumed by the lookahead. 391 | 392 | 393 | ```ts 394 | function IteratorMatcher(fn: (value: TimeJumpIterator) => Result): Matcher 395 | 396 | type TimeJumpIterator = Iterator & { 397 | readonly now: number, 398 | jump(time: number): void 399 | } 400 | ``` 401 | 402 | Use the `asInternalIterator` to pass an existing iterator into a `Matcher`. 403 | 404 | 405 | ```ts 406 | const matcher = group('a', 'b', 'c') 407 | 408 | matcher(asInternalIterator('abc')) ≡ { 409 | matched: true, 410 | value: ['a', 'b', 'c'], 411 | result: [ 412 | { matched: true, value: 'a' }, 413 | { matched: true, value: 'b' }, 414 | { matched: true, value: 'c' } 415 | ] 416 | } 417 | ``` 418 | 419 | ### Built-in `Matcher`s 420 | 421 | Directly useable `Matcher`s. 422 | 423 | - #### `any` 424 | 425 | 426 | ```ts 427 | const any: Matcher 428 | ``` 429 | 430 | Matches for any value, including `undefined`. 431 |
432 | Example 433 | 434 | 435 | ```js 436 | const matcher = any 437 | 438 | matcher(undefined) ≡ { matched: true, value: undefined } 439 | matcher({ key: 'value' }) ≡ { matched: true, value: { key: 'value' } } 440 | ``` 441 | 442 |
443 | 444 | - #### `defined` 445 | 446 | 447 | ```ts 448 | const defined: Matcher 449 | ``` 450 | 451 | Matches for any defined value, or in other words not `undefined`. 452 |
453 | Example 454 | 455 | 456 | ```js 457 | const matcher = defined 458 | 459 | matcher({ key: 'value' }) ≡ { matched: true, value: {key: 'value' } } 460 | 461 | matcher(undefined) ≡ { matched: false } 462 | ``` 463 | 464 |
465 | 466 | - #### `empty` 467 | 468 | 469 | ```ts 470 | const empty: Matcher<[] | {} | ''> 471 | ``` 472 | 473 | Matches either `[]`, `{}`, or `''` (empty string). 474 |
475 | Example 476 | 477 | 478 | ```js 479 | const matcher = empty 480 | 481 | matcher([]) ≡ { matched: true, value: [] } 482 | matcher({}) ≡ { matched: true, value: {} } 483 | matcher('') ≡ { matched: true, value: '' } 484 | 485 | matcher([42]) ≡ { matched: false } 486 | matcher({ key: 'value' }) ≡ { matched: false } 487 | matcher('alice') ≡ { matched: false } 488 | ``` 489 | 490 |
491 | 492 | ### `Matcher` builders 493 | 494 | Builders to create a `Matcher`. 495 | 496 | - #### `between` 497 | 498 | 499 | ```ts 500 | function between(lower: number, upper: number): Matcher 501 | ``` 502 | 503 | Matches if value is a `Number`, where `lower <= value < upper` 504 |
505 | Example 506 | 507 | 508 | ```js 509 | const matcher = between(10, 20) 510 | 511 | matcher(9) ≡ { matched: false } 512 | matcher(10) ≡ { matched: true, value: 10 } 513 | matcher(19) ≡ { matched: true, value: 19 } 514 | matcher(20) ≡ { matched: false } 515 | ``` 516 | 517 |
518 | 519 | - #### `equals` 520 | 521 | 522 | ```ts 523 | function equals(expected: T): Matcher 524 | ``` 525 | 526 | Matches `expected` if strictly equals `===` to value. 527 |
528 | Example 529 | 530 | 531 | ```js 532 | const matcher = equals('alice') 533 | 534 | matcher('alice') ≡ { matched: true, value: 'alice' } 535 | matcher(42) ≡ { matched: false } 536 | ``` 537 | 538 | 539 | ```js 540 | const matcher = equals(42) 541 | 542 | matcher('alice') ≡ { matched: false } 543 | matcher(42) ≡ { matched: true, value: 42 } 544 | ``` 545 | 546 | 547 | ```js 548 | const matcher = equals(undefined) 549 | 550 | matcher(undefined) ≡ { matched: true, value: undefined } 551 | matcher(42) ≡ { matched: false } 552 | ``` 553 | 554 |
555 | 556 | - #### `greaterThan` 557 | 558 | 559 | ```ts 560 | function greaterThan(expected: number): Matcher 561 | ``` 562 | 563 | Matches if value is a `Number`, where `expected < value` 564 |
565 | Example 566 | 567 | 568 | ```js 569 | const matcher = greaterThan(10) 570 | 571 | matcher(9) ≡ { matched: false } 572 | matcher(10) ≡ { matched: false } 573 | matcher(11) ≡ { matched: true, value: 11 } 574 | ``` 575 | 576 |
577 | 578 | - #### `greaterThanEquals` 579 | 580 | 581 | ```ts 582 | function greaterThanEquals(expected: number): Matcher 583 | ``` 584 | 585 | Matches if value is a `Number`, where `expected <= value` 586 |
587 | Example 588 | 589 | 590 | ```js 591 | const matcher = greaterThanEquals(10) 592 | 593 | matcher(9) ≡ { matched: false } 594 | matcher(10) ≡ { matched: true, value: 10 } 595 | matcher(11) ≡ { matched: true, value: 11 } 596 | ``` 597 | 598 |
599 | 600 | - #### `lessThan` 601 | 602 | 603 | ```ts 604 | function lessThan(expected: number): Matcher 605 | ``` 606 | 607 | Matches if value is a `Number`, where `expected > value` 608 |
609 | Example 610 | 611 | 612 | ```js 613 | const matcher = lessThan(10) 614 | 615 | matcher(9) ≡ { matched: true, value: 9 } 616 | matcher(10) ≡ { matched: false } 617 | matcher(11) ≡ { matched: false } 618 | ``` 619 | 620 |
621 | 622 | - #### `lessThanEquals` 623 | 624 | 625 | ```ts 626 | function lessThanEquals(expected: number): Matcher 627 | ``` 628 | 629 | Matches if value is a `Number`, where `expected >= value` 630 |
631 | Example 632 | 633 | 634 | ```js 635 | const matcher = lessThanEquals(10) 636 | 637 | matcher(9) ≡ { matched: true, value: 9 } 638 | matcher(10) ≡ { matched: true, value: 10 } 639 | matcher(11) ≡ { matched: false } 640 | ``` 641 | 642 |
643 | 644 | - #### `matchPredicate` 645 | 646 | 647 | ```ts 648 | function matchPredicate(predicate: (value: any) => Boolean): Matcher 649 | ``` 650 | 651 | Matches value that satisfies the predicate, or in other words `predicate(value) === true` 652 |
653 | Example 654 | 655 | 656 | ```js 657 | const isEven = (x) => x % 2 === 0 658 | const matcher = matchPredicate(isEven) 659 | 660 | matcher(2) ≡ { matched: true, value: 2 } 661 | 662 | matcher(1) ≡ { matched: false } 663 | ``` 664 | 665 |
666 | 667 | - #### `matchBigInt` 668 | 669 | 670 | ```ts 671 | function matchBigInt(expected?: bigint): Matcher 672 | ``` 673 | 674 | Matches if value is the `expected` BigInt. Matches any defined BigInt if `expected` is not provided. 675 |
676 | Example 677 | 678 | 679 | ```js 680 | const matcher = matchBigInt(42n) 681 | 682 | matcher(42n) ≡ { matched: true, value: 42n } 683 | 684 | matcher(69n) ≡ { matched: false } 685 | matcher(42) ≡ { matched: false } 686 | ``` 687 | 688 | 689 | ```js 690 | const matcher = matchBigInt() 691 | 692 | matcher(42n) ≡ { matched: true, value: 42n } 693 | matcher(69n) ≡ { matched: true, value: 69n } 694 | 695 | matcher(42) ≡ { matched: false } 696 | ``` 697 | 698 |
699 | 700 | - #### `matchNumber` 701 | 702 | 703 | ```ts 704 | function matchNumber(expected?: number): Matcher 705 | ``` 706 | 707 | Matches if value is the `expected` `Number`. Matches any defined `Number` if `expected` is not provided. 708 |
709 | Example 710 | 711 | 712 | ```js 713 | const matcher = matchNumber(42) 714 | 715 | matcher(42) ≡ { matched: true, value: 42 } 716 | 717 | matcher(69) ≡ { matched: false } 718 | matcher(42n) ≡ { matched: false } 719 | ``` 720 | 721 | 722 | ```js 723 | const matcher = matchNumber() 724 | 725 | matcher(42) ≡ { matched: true, value: 42 } 726 | matcher(69) ≡ { matched: true, value: 69 } 727 | 728 | matcher(42n) ≡ { matched: false } 729 | ``` 730 | 731 |
732 | 733 | - #### `matchProp` 734 | 735 | 736 | ```ts 737 | function matchProp(expected: string): Matcher 738 | ``` 739 | 740 | Matches if value has `expected` as a property key, or in other words `expected in value`. 741 |
742 | Example 743 | 744 | 745 | ```js 746 | const matcher = matchProp('x') 747 | 748 | matcher({ x: 42 }) ≡ { matched: true, value: { x: 42 } } 749 | 750 | matcher({ y: 42 }) ≡ { matched: false } 751 | matcher({}) ≡ { matched: false } 752 | ``` 753 | 754 |
755 | 756 | - #### `matchString` 757 | 758 | 759 | ```ts 760 | function matchString(expected?: string): Matcher 761 | ``` 762 | 763 | Matches if value is the `expected` `String`. Matches any defined `String` if `expected` is not provided. 764 |
765 | Example 766 | 767 | 768 | ```js 769 | const matcher = matchString('alice') 770 | 771 | matcher('alice') ≡ { matched: true, value: 'alice' } 772 | 773 | matcher('bob') ≡ { matched: false } 774 | ``` 775 | 776 | 777 | ```js 778 | const matcher = matchString() 779 | 780 | matcher('alice') ≡ { matched: true, value: 'alice' } 781 | 782 | matcher(undefined) ≡ { matched: false } 783 | matcher({ key: 'value' }) ≡ { matched: false } 784 | ``` 785 | 786 |
787 | 788 | - #### `matchRegExp` 789 | 790 | 791 | ```ts 792 | function matchRegExp(expected: RegExp): Matcher 793 | ``` 794 | 795 | Matches if value matches the `expected` `RegExp`. `Matched` will include the `RegExp` match object as the `matchedRegExp` property. 796 |
797 | Example 798 | 799 | 800 | ```js 801 | const matcher = matchRegExp(/^dear (\w+)$/) 802 | 803 | matcher('dear alice') ≡ { matched: true, value: 'dear alice', matchedRegExp: ['dear alice', 'alice'] } 804 | 805 | matcher('hello alice') ≡ { matched: false } 806 | ``` 807 | 808 | 809 | ```js 810 | const matcher = matchRegExp(/^dear (?\w+)$/) 811 | 812 | matcher('dear alice') ≡ { matched: true, value: 'dear alice', matchedRegExp: { groups: { name: 'alice' } } } 813 | 814 | matcher('hello alice') ≡ { matched: false } 815 | ``` 816 | 817 |
818 | 819 | ### `Matcher` composers 820 | 821 | Creates a `Matcher` from other `Matcher`s. 822 | 823 | - #### `matchArray` 824 | 825 | 826 | ```ts 827 | function matchArray(expected?: T[]): Matcher 828 | ``` 829 | 830 | Matches `expected` array completely. Primitives in `expected` are wrapped with their corresponding `Matcher` builder. `expected` array can also include [`IteratorMatcher`](#core-concept)s which can consume multiple elements. Matches any defined array if `expected` is not provided. 831 |
832 | Example 833 | 834 | 835 | ```js 836 | const matcher = matchArray([42, 'alice']) 837 | 838 | matcher([42, 'alice']) ≡ { 839 | matched: true, 840 | value: [42, 'alice'], 841 | result: [ 842 | { matched: true, value: 42 }, 843 | { matched: true, value: 'alice' } 844 | ] 845 | } 846 | 847 | matcher([42, 'alice', true, 69]) ≡ { matched: false } 848 | matcher(['alice', 42]) ≡ { matched: false } 849 | matcher([]) ≡ { matched: false } 850 | matcher([42]) ≡ { matched: false } 851 | ``` 852 | 853 | 854 | ```js 855 | const matcher = matchArray([42, 'alice', rest]) 856 | 857 | matcher([42, 'alice']) ≡ { 858 | matched: true, 859 | value: [42, 'alice', []], 860 | result: [ 861 | { matched: true, value: 42 }, 862 | { matched: true, value: 'alice' }, 863 | { matched: true, value: [] } 864 | ] 865 | } 866 | matcher([42, 'alice', true, 69]) ≡ { 867 | matched: true, 868 | value: [42, 'alice', [true, 69]], 869 | result: [ 870 | { matched: true, value: 42 }, 871 | { matched: true, value: 'alice' }, 872 | { matched: true, value: [true, 69] } 873 | ] 874 | } 875 | 876 | matcher(['alice', 42]) ≡ { matched: false } 877 | matcher([]) ≡ { matched: false } 878 | matcher([42]) ≡ { matched: false } 879 | ``` 880 | 881 | 882 | ```js 883 | const matcher = matchArray([maybe('alice'), 'bob']) 884 | 885 | matcher(['alice', 'bob']) ≡ { 886 | matched: true, 887 | value: ['alice', 'bob'], 888 | result: [ 889 | { matched: true, value: 'alice' }, 890 | { matched: true, value: 'bob' } 891 | ] 892 | } 893 | matcher(['bob']) ≡ { 894 | matched: true, 895 | value: [undefined, 'bob'], 896 | result: [ 897 | { matched: true, value: undefined }, 898 | { matched: true, value: 'bob' } 899 | ] 900 | } 901 | 902 | matcher(['eve', 'bob']) ≡ { matched: false } 903 | matcher(['eve']) ≡ { matched: false } 904 | ``` 905 | 906 | 907 | ```js 908 | const matcher = matchArray([some('alice'), 'bob']) 909 | 910 | matcher(['alice', 'alice', 'bob']) ≡ { 911 | matched: true, 912 | value: [['alice', 'alice'], 'bob'], 913 | result: [ 914 | { 915 | matched: true, 916 | value: ['alice', 'alice'], 917 | result: [ 918 | { matched: true, value: 'alice' }, 919 | { matched: true, value: 'alice' } 920 | ] 921 | }, 922 | { matched: true, value: 'bob' } 923 | ] 924 | } 925 | 926 | matcher(['eve', 'bob']) ≡ { matched: false } 927 | matcher(['bob']) ≡ { matched: false } 928 | ``` 929 | 930 | 931 | ```js 932 | const matcher = matchArray([group('alice', 'fred'), 'bob']) 933 | 934 | matcher(['alice', 'fred', 'bob']) ≡ { 935 | matched: true, 936 | value: [['alice', 'fred'], 'bob'], 937 | result: [ 938 | { 939 | matched: true, 940 | value: ['alice', 'fred'], 941 | result: [ 942 | { matched: true, value: 'alice' }, 943 | { matched: true, value: 'fred' } 944 | ] 945 | }, 946 | { matched: true, value: 'bob' } 947 | ] 948 | } 949 | 950 | matcher(['alice', 'eve', 'bob']) ≡ { matched: false } 951 | matcher(['eve', 'fred', 'bob']) ≡ { matched: false } 952 | matcher(['alice', 'bob']) ≡ { matched: false } 953 | matcher(['fred', 'bob']) ≡ { matched: false } 954 | matcher(['bob']) ≡ { matched: false } 955 | ``` 956 | 957 | 958 | ```js 959 | const matcher = matchArray() 960 | 961 | matcher([42, 'alice']) ≡ { 962 | matched: true, 963 | value: [42, 'alice'], 964 | result: [] 965 | } 966 | 967 | matcher(undefined) ≡ { matched: false } 968 | matcher({ key: 'value' }) ≡ { matched: false } 969 | ``` 970 | 971 |
972 | 973 | - #### `matchObject` 974 | 975 | 976 | ```ts 977 | function matchObject(expected?: T): Matcher 978 | ``` 979 | 980 | Matches `expected` enumerable object properties completely or partially with [`rest`](#rest) matcher. Primitives in `expected` are wrapped with their corresponding `Matcher` builder. The rest of properties can be found on the `value` with the rest key. Matches any defined object if `expected` is not provided. 981 |
982 | Example 983 | 984 | 985 | ```js 986 | const matcher = matchObject({ x: 42, y: 'alice' }) 987 | 988 | matcher({ x: 42, y: 'alice' }) ≡ { 989 | matched: true, 990 | value: { x: 42, y: 'alice' }, 991 | result: { 992 | x: { matched: true, value: 42 }, 993 | y: { matched: true, value: 'alice' } 994 | } 995 | } 996 | matcher({ y: 'alice', x: 42 }) ≡ { 997 | matched: true, 998 | value: { y: 'alice', x: 42 }, 999 | result: { 1000 | x: { matched: true, value: 42 }, 1001 | y: { matched: true, value: 'alice' } 1002 | } 1003 | } 1004 | 1005 | matcher({ x: 42, y: 'alice', z: true, aa: 69 }) ≡ { matched: false } 1006 | matcher({}) ≡ { matched: false } 1007 | matcher({ x: 42 }) ≡ { matched: false } 1008 | ``` 1009 | 1010 | 1011 | ```js 1012 | const matcher = matchObject({ x: 42, y: 'alice', rest }) 1013 | 1014 | matcher({ x: 42, y: 'alice' }) ≡ { 1015 | matched: true, 1016 | value: { x: 42, y: 'alice', rest: {} }, 1017 | result: { 1018 | x: { matched: true, value: 42 }, 1019 | y: { matched: true, value: 'alice' }, 1020 | rest: { matched: true, value: {} } 1021 | } 1022 | } 1023 | matcher({ x: 42, y: 'alice', z: true, aa: 69 }) ≡ { 1024 | matched: true, 1025 | value: { x: 42, y: 'alice', rest: { z: true, aa: 69 } }, 1026 | result: { 1027 | x: { matched: true, value: 42 }, 1028 | y: { matched: true, value: 'alice' }, 1029 | rest: { matched: true, value: { z: true, aa: 69 } } 1030 | } 1031 | } 1032 | 1033 | matcher({}) ≡ { matched: false } 1034 | matcher({ x: 42 }) ≡ { matched: false } 1035 | ``` 1036 | 1037 | 1038 | ```js 1039 | const matcher = matchObject({ x: 42, y: 'alice', customRestKey: rest }) 1040 | 1041 | matcher({ x: 42, y: 'alice', z: true }) ≡ { 1042 | matched: true, 1043 | value: { x: 42, y: 'alice', customRestKey: { z: true } }, 1044 | result: { 1045 | x: { matched: true, value: 42 }, 1046 | y: { matched: true, value: 'alice' }, 1047 | customRestKey: { matched: true, value: { z: true } } 1048 | } 1049 | } 1050 | ``` 1051 | 1052 | 1053 | ```js 1054 | const matcher = matchObject() 1055 | 1056 | matcher({ x: 42, y: 'alice' }) ≡ { 1057 | matched: true, 1058 | value: { x: 42, y: 'alice' }, 1059 | result: {} 1060 | } 1061 | 1062 | matcher(undefined) ≡ { matched: false } 1063 | matcher('alice') ≡ { matched: false } 1064 | ``` 1065 | 1066 |
1067 | 1068 | - #### `group` 1069 | 1070 | 1071 | 1072 | ```ts 1073 | function group(...expected: T[]): Matcher 1074 | ``` 1075 | 1076 | An [`IteratorMatcher`](#core-concept) that consumes all a sequence of element matching `expected` array. Similar to regular expression group. 1077 |
1078 | Example 1079 | 1080 | 1081 | 1082 | ```js 1083 | const matcher = matchArray([group('alice', 'fred'), 'bob']) 1084 | 1085 | matcher(['alice', 'fred', 'bob']) ≡ { 1086 | matched: true, 1087 | value: [['alice', 'fred'], 'bob'], 1088 | result: [ 1089 | { 1090 | matched: true, 1091 | value: ['alice', 'fred'], 1092 | result: [ 1093 | { matched: true, value: 'alice' }, 1094 | { matched: true, value: 'fred' } 1095 | ] 1096 | }, 1097 | { matched: true, value: 'bob' } 1098 | ] 1099 | } 1100 | 1101 | matcher(['alice', 'eve', 'bob']) ≡ { matched: false } 1102 | matcher(['eve', 'fred', 'bob']) ≡ { matched: false } 1103 | matcher(['alice', 'bob']) ≡ { matched: false } 1104 | matcher(['fred', 'bob']) ≡ { matched: false } 1105 | matcher(['bob']) ≡ { matched: false } 1106 | ``` 1107 | 1108 | 1109 | ```js 1110 | const matcher = matchArray([group(maybe('alice'), 'fred'), 'bob']) 1111 | 1112 | matcher(['fred', 'bob']) ≡ { 1113 | matched: true, 1114 | value: [[undefined, 'fred'], 'bob'], 1115 | result: [ 1116 | { 1117 | matched: true, 1118 | value: [undefined, 'fred'], 1119 | result: [ 1120 | { matched: true, value: undefined }, 1121 | { matched: true, value: 'fred' } 1122 | ] 1123 | }, 1124 | { matched: true, value: 'bob' } 1125 | ] 1126 | } 1127 | matcher(['alice', 'fred', 'bob']) ≡ { 1128 | matched: true, 1129 | value: [['alice', 'fred'], 'bob'], 1130 | result: [ 1131 | { 1132 | matched: true, 1133 | value: ['alice', 'fred'], 1134 | result: [ 1135 | { matched: true, value: 'alice' }, 1136 | { matched: true, value: 'fred' } 1137 | ] 1138 | }, 1139 | { matched: true, value: 'bob' } 1140 | ] 1141 | } 1142 | ``` 1143 | 1144 | 1145 | ```js 1146 | const matcher = matchArray([group(some('alice'), 'fred'), 'bob']) 1147 | 1148 | matcher(['alice', 'fred', 'bob']) ≡ { 1149 | matched: true, 1150 | value: [[['alice'], 'fred'], 'bob'], 1151 | result: [ 1152 | { 1153 | matched: true, 1154 | value: [['alice'], 'fred'], 1155 | result: [ 1156 | { 1157 | matched: true, 1158 | value: ['alice'], 1159 | result: [ 1160 | { matched: true, value: 'alice' } 1161 | ] 1162 | }, 1163 | { matched: true, value: 'fred' } 1164 | ] 1165 | }, 1166 | { matched: true, value: 'bob' } 1167 | ] 1168 | } 1169 | matcher(['alice', 'alice', 'fred', 'bob']) ≡ { 1170 | matched: true, 1171 | value: [[['alice', 'alice'], 'fred'], 'bob'], 1172 | result: [ 1173 | { 1174 | matched: true, 1175 | value: [['alice', 'alice'], 'fred'], 1176 | result: [ 1177 | { 1178 | matched: true, 1179 | value: ['alice', 'alice'], 1180 | result: [ 1181 | { matched: true, value: 'alice' }, 1182 | { matched: true, value: 'alice' } 1183 | ] 1184 | }, 1185 | { matched: true, value: 'fred' } 1186 | ] 1187 | }, 1188 | { matched: true, value: 'bob' } 1189 | ] 1190 | } 1191 | 1192 | matcher(['fred', 'bob']) ≡ { matched: false } 1193 | ``` 1194 | 1195 |
1196 | 1197 | - #### `maybe` 1198 | 1199 | 1200 | ```ts 1201 | function maybe(expected: T): Matcher 1202 | ``` 1203 | 1204 | An [`IteratorMatcher`](#core-concept) that consumes an element in the array if it matches `expected`, otherwise does nothing. The unmatched element can be consumed by the next matcher. Similar to the regular expression `?` operator. 1205 |
1206 | Example 1207 | 1208 | 1209 | ```js 1210 | const matcher = matchArray([maybe('alice'), 'bob']) 1211 | 1212 | matcher(['alice', 'bob']) ≡ { 1213 | matched: true, 1214 | value: ['alice', 'bob'], 1215 | result: [ 1216 | { matched: true, value: 'alice' }, 1217 | { matched: true, value: 'bob' } 1218 | ] 1219 | } 1220 | matcher(['bob']) ≡ { 1221 | matched: true, 1222 | value: [undefined, 'bob'], 1223 | result: [ 1224 | { matched: true, value: undefined }, 1225 | { matched: true, value: 'bob' } 1226 | ] 1227 | } 1228 | 1229 | matcher(['eve', 'bob']) ≡ { matched: false } 1230 | matcher(['eve']) ≡ { matched: false } 1231 | ``` 1232 | 1233 | 1234 | ```js 1235 | const matcher = matchArray([maybe(group('alice', 'fred')), 'bob']) 1236 | 1237 | matcher(['alice', 'fred', 'bob']) ≡ { 1238 | matched: true, 1239 | value: [['alice', 'fred'], 'bob'], 1240 | result: [ 1241 | { 1242 | matched: true, 1243 | value: ['alice', 'fred'], 1244 | result: [ 1245 | { matched: true, value: 'alice' }, 1246 | { matched: true, value: 'fred' } 1247 | ] 1248 | }, 1249 | { matched: true, value: 'bob' }, 1250 | ] 1251 | } 1252 | matcher(['bob']) ≡ { 1253 | matched: true, 1254 | value: [undefined, 'bob'], 1255 | result: [ 1256 | { matched: true, value: undefined }, 1257 | { matched: true, value: 'bob' } 1258 | ] 1259 | } 1260 | ``` 1261 | 1262 | 1263 | ```js 1264 | const matcher = matchArray([maybe(some('alice')), 'bob']) 1265 | 1266 | matcher(['alice', 'bob']) ≡ { 1267 | matched: true, 1268 | value: [['alice'], 'bob'], 1269 | result: [ 1270 | { 1271 | matched: true, 1272 | value: ['alice'], 1273 | result: [ 1274 | { matched: true, value: 'alice' } 1275 | ] 1276 | }, 1277 | { matched: true, value: 'bob' } 1278 | ] 1279 | } 1280 | matcher(['alice', 'alice', 'bob']) ≡ { 1281 | matched: true, 1282 | value: [['alice', 'alice'], 'bob'], 1283 | result: [ 1284 | { 1285 | matched: true, 1286 | value: ['alice', 'alice'], 1287 | result: [ 1288 | { matched: true, value: 'alice' }, 1289 | { matched: true, value: 'alice' } 1290 | ] 1291 | }, 1292 | { matched: true, value: 'bob' } 1293 | ] 1294 | } 1295 | matcher(['bob']) ≡ { 1296 | matched: true, 1297 | value: [undefined, 'bob'], 1298 | result: [ 1299 | { matched: true, value: undefined }, 1300 | { matched: true, value: 'bob' } 1301 | ] 1302 | } 1303 | ``` 1304 | 1305 |
1306 | 1307 | - #### `not` 1308 | 1309 | 1310 | ```ts 1311 | function not(unexpected: T): Matcher 1312 | ``` 1313 | 1314 | Matches if value does not match `unexpected`. Primitives in `unexpected` are wrapped with their corresponding `Matcher` builder 1315 |
1316 | Example 1317 | 1318 | 1319 | ```js 1320 | const matcher = not(oneOf('alice', 'bob')) 1321 | 1322 | matcher('eve') ≡ { matched: true, value: 'eve' } 1323 | 1324 | matcher('alice') ≡ { matched: false } 1325 | matcher('bob') ≡ { matched: false } 1326 | ``` 1327 | 1328 |
1329 | 1330 | - #### `rest` 1331 | 1332 | 1333 | ```ts 1334 | const rest: Matcher 1335 | ``` 1336 | 1337 | An [`IteratorMatcher`](#core-concept) that consumes the remaining elements/properties to prefix matching of arrays and partial matching of objects. 1338 |
1339 | Example 1340 | 1341 | 1342 | ```js 1343 | const matcher = when( 1344 | { 1345 | headers: [ 1346 | { name: 'cookie', value: defined }, 1347 | rest 1348 | ], 1349 | rest 1350 | }, 1351 | ( 1352 | { headers: [{ value: cookieValue }, restOfHeaders], rest: restOfResponse }, 1353 | ) => ({ 1354 | cookieValue, 1355 | restOfHeaders, 1356 | restOfResponse 1357 | }) 1358 | ) 1359 | 1360 | matcher({ 1361 | status: 200, 1362 | headers: [ 1363 | { name: 'cookie', value: 'om' }, 1364 | { name: 'accept', value: 'everybody' } 1365 | ] 1366 | }) ≡ { 1367 | cookieValue: 'om', 1368 | restOfHeaders: [{ name: 'accept', value: 'everybody' }], 1369 | restOfResponse: { status: 200 } 1370 | } 1371 | 1372 | matcher(undefined) ≡ { matched: false } 1373 | matcher({ key: 'value' }) ≡ { matched: false } 1374 | ``` 1375 | 1376 | 1377 | ```js 1378 | const matcher = matchArray([42, 'alice', rest]) 1379 | 1380 | matcher([42, 'alice']) ≡ { 1381 | matched: true, 1382 | value: [42, 'alice', []], 1383 | result: [ 1384 | { matched: true, value: 42 }, 1385 | { matched: true, value: 'alice' }, 1386 | { matched: true, value: [] } 1387 | ] 1388 | } 1389 | matcher([42, 'alice', true, 69]) ≡ { 1390 | matched: true, 1391 | value: [42, 'alice', [true, 69]], 1392 | result: [ 1393 | { matched: true, value: 42 }, 1394 | { matched: true, value: 'alice' }, 1395 | { matched: true, value: [true, 69] } 1396 | ] 1397 | } 1398 | 1399 | matcher(['alice', 42]) ≡ { matched: false } 1400 | matcher([]) ≡ { matched: false } 1401 | matcher([42]) ≡ { matched: false } 1402 | ``` 1403 | 1404 | 1405 | ```js 1406 | const matcher = matchObject({ x: 42, y: 'alice', rest }) 1407 | 1408 | matcher({ x: 42, y: 'alice' }) ≡ { 1409 | matched: true, 1410 | value: { x: 42, y: 'alice', rest: {} }, 1411 | result: { 1412 | x: { matched: true, value: 42 }, 1413 | y: { matched: true, value: 'alice' }, 1414 | rest: { matched: true, value: {} } 1415 | } 1416 | } 1417 | matcher({ x: 42, y: 'alice', z: true, aa: 69 }) ≡ { 1418 | matched: true, 1419 | value: { x: 42, y: 'alice', rest: { z: true, aa: 69 } }, 1420 | result: { 1421 | x: { matched: true, value: 42 }, 1422 | y: { matched: true, value: 'alice' }, 1423 | rest: { matched: true, value: { z: true, aa: 69 } } 1424 | } 1425 | } 1426 | 1427 | matcher({}) ≡ { matched: false } 1428 | matcher({ x: 42 }) ≡ { matched: false } 1429 | ``` 1430 | 1431 | 1432 | ```js 1433 | const matcher = matchObject({ x: 42, y: 'alice', customRestKey: rest }) 1434 | 1435 | matcher({ x: 42, y: 'alice', z: true }) ≡ { 1436 | matched: true, 1437 | value: { x: 42, y: 'alice', customRestKey: { z: true } }, 1438 | result: { 1439 | x: { matched: true, value: 42 }, 1440 | y: { matched: true, value: 'alice' }, 1441 | customRestKey: { matched: true, value: { z: true } } 1442 | } 1443 | } 1444 | ``` 1445 | 1446 |
1447 | 1448 | - #### `some` 1449 | 1450 | 1451 | ```ts 1452 | function some(expected: T): Matcher 1453 | ``` 1454 | 1455 | An [`IteratorMatcher`](#core-concept) consumes all consecutive elements matching `expected` in the array until it reaches the end or encounters an unmatched element. The next matcher can consume the unmatched element. At least one element must match. Similar to regular expression `+` operator. **`some` does not compose with matchers that consume nothing, such as `maybe`**. Attempting to compose with `maybe` will throw an error as it would otherwise lead to an infinite loop. 1456 |
1457 | Example 1458 | 1459 | 1460 | ```js 1461 | const matcher = matchArray([some('alice'), 'bob']) 1462 | 1463 | matcher(['alice', 'alice', 'bob']) ≡ { 1464 | matched: true, 1465 | value: [['alice', 'alice'], 'bob'], 1466 | result: [ 1467 | { 1468 | matched: true, 1469 | value: ['alice', 'alice'], 1470 | result: [ 1471 | { matched: true, value: 'alice' }, 1472 | { matched: true, value: 'alice' } 1473 | ] 1474 | }, 1475 | { matched: true, value: 'bob' } 1476 | ] 1477 | } 1478 | 1479 | matcher(['eve', 'bob']) ≡ { matched: false } 1480 | matcher(['bob']) ≡ { matched: false } 1481 | ``` 1482 | 1483 | 1484 | ```js 1485 | const matcher = matchArray([some(group('alice', 'fred')), 'bob']) 1486 | 1487 | matcher(['alice', 'fred', 'bob']) ≡ { 1488 | matched: true, 1489 | value: [[['alice', 'fred']], 'bob'], 1490 | result: [ 1491 | { 1492 | matched: true, 1493 | value: [['alice', 'fred']], 1494 | result: [ 1495 | { 1496 | matched: true, 1497 | value: ['alice', 'fred'], 1498 | result: [ 1499 | { matched: true, value: 'alice' }, 1500 | { matched: true, value: 'fred' } 1501 | ] 1502 | } 1503 | ] 1504 | }, 1505 | { matched: true, value: 'bob' } 1506 | ] 1507 | } 1508 | matcher(['alice', 'fred', 'alice', 'fred', 'bob']) ≡ { 1509 | matched: true, 1510 | value: [[['alice', 'fred'], ['alice', 'fred']], 'bob'], 1511 | result: [ 1512 | { 1513 | matched: true, 1514 | value: [['alice', 'fred'], ['alice', 'fred']], 1515 | result: [ 1516 | { 1517 | matched: true, 1518 | value: ['alice', 'fred'], 1519 | result: [ 1520 | { matched: true, value: 'alice' }, 1521 | { matched: true, value: 'fred' } 1522 | ] 1523 | }, 1524 | { 1525 | matched: true, 1526 | value: ['alice', 'fred'], 1527 | result: [ 1528 | { matched: true, value: 'alice' }, 1529 | { matched: true, value: 'fred' } 1530 | ] 1531 | } 1532 | ] 1533 | }, 1534 | { matched: true, value: 'bob' } 1535 | ] 1536 | } 1537 | ``` 1538 | 1539 |
1540 | 1541 | - #### `allOf` 1542 | 1543 | 1544 | ```ts 1545 | function allOf(expected: ...T): Matcher 1546 | ``` 1547 | 1548 | Matches if all `expected` matchers are matched. Primitives in `expected` are wrapped with their corresponding `Matcher` builder. Always matches if `expected` is empty. 1549 |
1550 | Example 1551 | 1552 | 1553 | ```js 1554 | const isEven = (x) => x % 2 === 0 1555 | const matchEven = matchPredicate(isEven) 1556 | 1557 | const matcher = allOf(between(1, 10), matchEven) 1558 | 1559 | matcher(2) ≡ { 1560 | matched: true, 1561 | value: 2, 1562 | result: [ 1563 | { matched: true, value: 2 }, 1564 | { matched: true, value: 2 } 1565 | ] 1566 | } 1567 | 1568 | matcher(0) ≡ { matched: false } 1569 | matcher(1) ≡ { matched: false } 1570 | matcher(12) ≡ { matched: false } 1571 | ``` 1572 | 1573 | 1574 | ```js 1575 | const matcher = allOf() 1576 | 1577 | matcher(undefined) ≡ { matched: true, value: undefined, result: [] } 1578 | matcher({ key: 'value' }) ≡ { matched: true, value: { key: 'value' }, result: [] } 1579 | ``` 1580 | 1581 |
1582 | 1583 | - #### `oneOf` 1584 | 1585 | 1586 | ```ts 1587 | function oneOf(expected: ...T): Matcher 1588 | ``` 1589 | 1590 | Matches first `expected` matcher that matches. Primitives in `expected` are wrapped with their corresponding `Matcher` builder. Always unmatched when empty `expected`. Similar to [`match`](#match). Similar to the regular expression `|` operator. 1591 |
1592 | Example 1593 | 1594 | 1595 | ```js 1596 | const matcher = oneOf('alice', 'bob') 1597 | 1598 | matcher('alice') ≡ { matched: true, value: 'alice' } 1599 | matcher('bob') ≡ { matched: true, value: 'bob' } 1600 | 1601 | matcher('eve') ≡ { matched: false } 1602 | ``` 1603 | 1604 | 1605 | ```js 1606 | const matcher = oneOf() 1607 | 1608 | matcher(undefined) ≡ { matched: false } 1609 | matcher({ key: 'value' }) ≡ { matched: false } 1610 | ``` 1611 | 1612 |
1613 | 1614 | - #### `when` 1615 | 1616 | 1617 | ```ts 1618 | type ValueMapper = (value: T, matched: Matched) => R 1619 | 1620 | function when( 1621 | expected?: T, 1622 | ...guards: ValueMapper, 1623 | valueMapper: ValueMapper 1624 | ): Matcher 1625 | ``` 1626 | 1627 | Matches if `expected` matches and satisfies all the `guards`, then matched value is transformed with `valueMapper`. `guards` are optional. Primative `expected` are wrapped with their corresponding `Matcher` builder. Second parameter to `valueMapper` is the `Matched` `Result`. See [`matchRegExp`](#matchregexp), [`matchArray`](#matcharray), [`matchObject`](#matchobject), [`group`](#group), [`some`](#some) and [`allOf`](#allof) for extra fields on `Matched`. 1628 |
1629 | Example 1630 | 1631 | 1632 | ```js 1633 | const matcher = when( 1634 | { role: 'teacher', surname: defined }, 1635 | ({ surname }) => `Good morning ${surname}` 1636 | ) 1637 | 1638 | matcher({ role: 'teacher', surname: 'Wong' }) ≡ { 1639 | matched: true, 1640 | value: 'Good morning Wong', 1641 | result: { 1642 | role: { matched: true, value: 'teacher' }, 1643 | surname: { matched: true, value: 'Wong' } 1644 | } 1645 | } 1646 | 1647 | matcher({ role: 'student' }) ≡ { matched: false } 1648 | ``` 1649 | 1650 | 1651 | ```js 1652 | const matcher = when( 1653 | { role: 'teacher', surname: defined }, 1654 | ({ surname }) => surname.length === 4, // guard 1655 | ({ surname }) => `Good morning ${surname}` 1656 | ) 1657 | 1658 | matcher({ role: 'teacher', surname: 'Wong' }) ≡ { 1659 | matched: true, 1660 | value: 'Good morning Wong', 1661 | result: { 1662 | role: { matched: true, value: 'teacher' }, 1663 | surname: { matched: true, value: 'Wong' } 1664 | } 1665 | } 1666 | 1667 | matcher({ role: 'teacher', surname: 'Smith' }) ≡ { matched: false } 1668 | ``` 1669 | 1670 |
1671 | 1672 | - #### `otherwise` 1673 | 1674 | 1675 | ```ts 1676 | type ValueMapper = (value: T, matched: Matched) => R 1677 | 1678 | function otherwise( 1679 | ..guards: ValueMapper, 1680 | valueMapper: ValueMapper 1681 | ): Matcher 1682 | ``` 1683 | 1684 | Matches if satisfies all the `guards`, then value is transformed with `valueMapper`. `guards` are optional. Second parameter to `valueMapper` is the `Matched` `Result`. See [`matchRegExp`](#matchregexp), [`matchArray`](#matcharray), [`matchObject`](#matchobject), [`group`](#group), [`some`](#some) and [`allOf`](#allof) for extra fields on `Matched`. 1685 |
1686 | Example 1687 | 1688 | 1689 | ```js 1690 | const matcher = otherwise( 1691 | ({ surname }) => `Good morning ${surname}` 1692 | ) 1693 | 1694 | matcher({ role: 'teacher', surname: 'Wong' }) ≡ { 1695 | matched: true, 1696 | value: 'Good morning Wong' 1697 | } 1698 | ``` 1699 | 1700 | 1701 | ```js 1702 | const matcher = otherwise( 1703 | ({ surname }) => surname.length === 4, // guard 1704 | ({ surname }) => `Good morning ${surname}` 1705 | ) 1706 | 1707 | matcher({ role: 'teacher', surname: 'Wong' }) ≡ { 1708 | matched: true, 1709 | value: 'Good morning Wong' 1710 | } 1711 | 1712 | matcher({ role: 'teacher', surname: 'Smith' }) ≡ { matched: false } 1713 | ``` 1714 | 1715 |
1716 | 1717 | ### `Matcher` consumers 1718 | 1719 | Consumes `Matcher`s to produce a value. 1720 | 1721 | - #### `match` 1722 | 1723 | 1724 | ```ts 1725 | const match: (value: T) => (...clauses: Matcher) => R | undefined 1726 | ``` 1727 | 1728 | Returns a matched value for the first clause that matches, or `undefined` if all are unmatched. `match` is to be used as a top-level expression and is not composable. To create a matcher composed of clauses use [`oneOf`](#oneof). 1729 |
1730 | Example 1731 | 1732 | 1733 | ```js 1734 | function meme(value) { 1735 | return match (value) ( 1736 | when (69, () => 'nice'), 1737 | otherwise (() => 'meh') 1738 | ) 1739 | } 1740 | 1741 | meme(69) ≡ 'nice' 1742 | meme(42) ≡ 'meh' 1743 | ``` 1744 | 1745 | 1746 | ```js 1747 | function meme(value) { 1748 | return match (value) ( 1749 | when (69, () => 'nice') 1750 | ) 1751 | } 1752 | 1753 | meme(69) ≡ 'nice' 1754 | meme(42) ≡ undefined 1755 | 1756 | const memeMatcher = oneOf ( 1757 | when (69, () => 'nice') 1758 | ) 1759 | 1760 | memeMatcher(69) ≡ { matched: true, value: 'nice' } 1761 | memeMatcher(42) ≡ { matched: false } 1762 | ``` 1763 | 1764 |
1765 | 1766 | ## What about [TC39 pattern matching proposal](https://github.com/tc39/proposal-pattern-matching)? 1767 | 1768 | `patcom` does not implement the semantics of TC39 pattern matching proposal. However, `patcom` was inspired by the TC39 pattern matching proposal and, in-fact, has feature parity. As `patcom` is a JavaScript library, it cannot introduce any new syntax, but the syntax remains relatively similar. 1769 | 1770 | ### Comparision of TC39 pattern matching proposal on the left to `patcom` on the right 1771 | 1772 | ![tc39 comparision](https://cdn.jsdelivr.net/gh/concept-not-found/patcom/tc39-proposal-pattern-matching/diff.png) 1773 | 1774 | ### Differences 1775 | 1776 | The most notable difference is `patcom` implemented [enumerable object properties](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Enumerability_and_ownership_of_properties) matching, whereas TC39 pattern matching proposal implements partial object matching. See [tc39/proposal-pattern-matching#243](https://github.com/tc39/proposal-pattern-matching/issues/243). The [`rest`](#rest) matcher can be used to achieve partial object matching. 1777 | 1778 | `patcom` also handles holes in arrays differently. Holes in arrays in TC39 pattern matching proposal will match anything, whereas `patcom` uses the more literal meaning of `undefined` as one would expect with holes in arrays defined in standard JavaScript. The [`any`](#any) matcher must be explicitly used if one desires to match anything for a specific array position. 1779 | 1780 | Since `patcom` had to separate the pattern matching from destructuring, enumerable object properties matching is the most sensible. Syntactically separation of the pattern from destructuring is the most significant difference. 1781 | 1782 | #### TC39 pattern matching proposal `when` syntax shape 1783 | 1784 | 1785 | ```js 1786 | when ( 1787 | pattern + destructuring 1788 | ) if guard: 1789 | expression 1790 | ``` 1791 | 1792 | #### `patcom` `when` syntax shape 1793 | 1794 | 1795 | ```js 1796 | when ( 1797 | pattern, 1798 | (destructuring) => guard, 1799 | (destructuring) => expression 1800 | ) 1801 | ``` 1802 | 1803 | `patcom` offers [`allOf`](#allof) and [`oneOf`](#oneof) matchers as subsitute for the [pattern combinators](https://github.com/tc39/proposal-pattern-matching#pattern-combinators) syntax. 1804 | 1805 | #### TC39 pattern matching proposal `and` combinator + `or` combinator 1806 | 1807 | Note that the usage of `and` in this example is purely to capture the match and assign it to `dir`. 1808 | 1809 | 1810 | ```js 1811 | when ( 1812 | ['go', dir and ('north' or 'east' or 'south' or 'west')] 1813 | ): 1814 | ...use dir 1815 | ``` 1816 | 1817 | #### `patcom` `oneOf` matcher + destructuring 1818 | 1819 | Assignment to `dir` separated from the pattern. 1820 | 1821 | 1822 | ```js 1823 | when ( 1824 | ['go', oneOf('north', 'east', 'south', 'west')], 1825 | ([, dir]) => 1826 | ...use dir 1827 | ) 1828 | ``` 1829 | 1830 | Additional consequence of the separating the pattern from destructuring is `patcom` has no need for any of: 1831 | 1832 | - [interpolation pattern](https://github.com/tc39/proposal-pattern-matching#interpolation-pattern) syntax 1833 | - [custom matcher protocol interpolations](https://github.com/tc39/proposal-pattern-matching#custom-matcher-protocol-interpolations) syntax 1834 | - [`with` chaining](https://github.com/tc39/proposal-pattern-matching#with-chaining) syntax. 1835 | 1836 | Another difference is TC39 pattern matching proposal caches iterators and object property accesses. This has been implemented in `patcom` as a different variation of `match`, which is powered by `cachingOneOf`. 1837 | 1838 | To see a complete comparison with TC39 pattern matching proposal and unit tests to prove full feature parity, see [tc39-proposal-pattern-matching folder](https://github.com/concept-not-found/patcom/tree/master/tc39-proposal-pattern-matching). 1839 | 1840 | ## What about [`match-iz`](https://github.com/shuckster/match-iz)? 1841 | 1842 | `match-iz` is similarly inspired by TC39 pattern matching proposal has many similarities to `patcom`. However, `match-iz` is not feature complete to TC39 pattern matching proposal, most notably missing is: 1843 | 1844 | - when guards 1845 | - caching iterators and object property accesses 1846 | 1847 | `match-iz` also offers a different match result API, where `matched` and `value` are allowed to be functions. The same functionality in `patcom` can be found in the form of [functional mappers](https://github.com/concept-not-found/patcom/blob/master/mappers.js). 1848 | 1849 | ## Contributions welcome 1850 | 1851 | The following is a non-exhaustive list of features that could be implemented in the future: 1852 | 1853 | - more unit testing 1854 | - better documentation 1855 | - executable examples 1856 | - tests that extract and execute samples out of documentation 1857 | - richer set of matchers 1858 | - as this library exports modules, the size of the npm package does not matter if consumed by a tree shaking bundler. This means matchers of any size will be accepted as long as all matchers can be organized well as a cohesive set 1859 | - [Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) matcher 1860 | - [Temporal](https://github.com/tc39/proposal-temporal) matchers 1861 | - [Typed array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects#indexed_collections) matchers 1862 | - [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) matcher 1863 | - [Set](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set) matcher 1864 | - [Intl](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl) matchers 1865 | - [Dom](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model) matchers 1866 | - other [Web API](https://developer.mozilla.org/en-US/docs/Web/API) matchers 1867 | - eslint 1868 | - typescript, either by rewrite or `.d.ts` files 1869 | - async matchers 1870 | 1871 | [`patcom` is seeking funding](https://ko-fi.com/pyrolistical) 1872 | 1873 | ## What does `patcom` mean? 1874 | 1875 | `patcom` is short for pattern combinator, as `patcom` is the same concept as [parser combinator](https://en.wikipedia.org/wiki/Parser_combinator) 1876 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { oneOf } from './matchers/index.js' 2 | export * from './matchers/index.js' 3 | export * from './mappers.js' 4 | 5 | export const match = 6 | (value) => 7 | (...matchers) => { 8 | const result = oneOf(...matchers)(value) 9 | return result.value 10 | } 11 | -------------------------------------------------------------------------------- /mappers.js: -------------------------------------------------------------------------------- 1 | export const mapMatched = (valueMapper) => (result) => { 2 | return { 3 | ...result, 4 | value: valueMapper(result.value, result), 5 | } 6 | } 7 | 8 | export const mapMatchedResult = (matchedMapper) => (result) => { 9 | if (result.matched) { 10 | return matchedMapper(result) 11 | } else { 12 | return result 13 | } 14 | } 15 | 16 | export const mapResult = (valueMapper) => 17 | mapMatchedResult(mapMatched(valueMapper)) 18 | 19 | export const mapResultMatcher = (resultMapper) => (matcher) => (value) => 20 | resultMapper(matcher(value)) 21 | 22 | export const mapMatchedMatcher = (matchedMapper) => 23 | mapResultMatcher(mapMatchedResult(matchedMapper)) 24 | 25 | export const mapMatcher = (valueMapper) => 26 | mapResultMatcher(mapResult(valueMapper)) 27 | -------------------------------------------------------------------------------- /matchers/index.js: -------------------------------------------------------------------------------- 1 | import TimeJumpIterator from '../time-jump-iterator.js' 2 | import { mapMatcher, mapResultMatcher } from '../mappers.js' 3 | 4 | export const unmatched = { 5 | matched: false, 6 | } 7 | 8 | const internalIterator = Symbol('internal iterator') 9 | 10 | export const matcher = Symbol('matcher') 11 | export const ValueMatcher = (fn) => 12 | IteratorMatcher((iterator) => { 13 | const { value, done } = iterator.next() 14 | if (done) { 15 | return unmatched 16 | } 17 | return fn(value) 18 | }) 19 | 20 | export const IteratorMatcher = (fn) => { 21 | fn[matcher] = fn 22 | return (value) => { 23 | const iterator = 24 | typeof value === 'object' && value[internalIterator] 25 | ? value 26 | : TimeJumpIterator([value][Symbol.iterator]()) 27 | iterator[internalIterator] = iterator 28 | return fn(iterator) 29 | } 30 | } 31 | 32 | export const asInternalIterator = (source) => { 33 | const iterator = TimeJumpIterator(source[Symbol.iterator]()) 34 | iterator[internalIterator] = iterator 35 | return iterator 36 | } 37 | 38 | export const asMatcher = (matchable) => { 39 | if (matchable === undefined) { 40 | return equals(undefined) 41 | } 42 | if (matchable[matcher]) { 43 | return matchable[matcher] 44 | } 45 | if (Array.isArray(matchable)) { 46 | return matchArray(matchable) 47 | } 48 | if (matchable instanceof RegExp) { 49 | return matchRegExp(matchable) 50 | } 51 | if (matchable instanceof String) { 52 | return matchString(matchable) 53 | } 54 | switch (typeof matchable) { 55 | case 'boolean': 56 | return matchBoolean(matchable) 57 | case 'number': 58 | return matchNumber(matchable) 59 | case 'string': 60 | return matchString(matchable) 61 | case 'object': 62 | return matchObject(matchable) 63 | } 64 | if (typeof matchable !== 'function') { 65 | throw new Error(`unable to create matcher from ${matchable}`) 66 | } 67 | return matchable 68 | } 69 | 70 | export const maybe = (expected) => 71 | IteratorMatcher((iterator) => { 72 | const time = iterator.now 73 | const matcher = asMatcher(expected) 74 | const result = matcher(iterator) 75 | if (result.matched) { 76 | return result 77 | } else { 78 | iterator.jump(time) 79 | return { 80 | matched: true, 81 | value: undefined, 82 | } 83 | } 84 | }) 85 | 86 | export const group = (...expected) => 87 | IteratorMatcher((iterator) => { 88 | const values = [] 89 | const results = [] 90 | for (const element of expected) { 91 | const matcher = asMatcher(element) 92 | const result = matcher(iterator) 93 | if (result.matched) { 94 | values.push(result.value) 95 | results.push(result) 96 | } else { 97 | return unmatched 98 | } 99 | } 100 | return { 101 | matched: true, 102 | value: values, 103 | result: results, 104 | } 105 | }) 106 | 107 | export const some = (expected) => 108 | IteratorMatcher((iterator) => { 109 | const values = [] 110 | const results = [] 111 | const matcher = asMatcher(expected) 112 | while (true) { 113 | const time = iterator.now 114 | const result = matcher(iterator) 115 | const end = iterator.now 116 | if (result.matched) { 117 | if (time === end) { 118 | throw new Error( 119 | 'some will infinite loop if expected matches but consumes no elements. some of maybe is not possible' 120 | ) 121 | } 122 | values.push(result.value) 123 | results.push(result) 124 | } else { 125 | if (results.length === 0) { 126 | return unmatched 127 | } 128 | iterator.jump(time) 129 | return { 130 | matched: true, 131 | value: values, 132 | result: results, 133 | } 134 | } 135 | } 136 | }) 137 | 138 | export const rest = IteratorMatcher((iterator) => { 139 | return { 140 | matched: true, 141 | value: [...iterator], 142 | } 143 | }) 144 | 145 | export const matchArray = (expected) => 146 | ValueMatcher((value) => { 147 | const valueIsANumberBooleanStringOrFunction = typeof value !== 'object' 148 | if ( 149 | value === undefined || 150 | !value[Symbol.iterator] || 151 | valueIsANumberBooleanStringOrFunction 152 | ) { 153 | return unmatched 154 | } 155 | if (expected === undefined) { 156 | if (!Array.isArray(value)) { 157 | value = [...value] 158 | } 159 | return { 160 | matched: true, 161 | value, 162 | result: [], 163 | } 164 | } 165 | const iterator = asInternalIterator(value) 166 | const values = [] 167 | const results = [] 168 | for (const element of expected) { 169 | const matcher = asMatcher(element) 170 | const result = matcher(iterator) 171 | if (result.matched) { 172 | values.push(result.value) 173 | results.push(result) 174 | continue 175 | } 176 | return unmatched 177 | } 178 | if (iterator.next().done) { 179 | return { 180 | matched: true, 181 | value: values, 182 | result: results, 183 | } 184 | } else { 185 | return unmatched 186 | } 187 | }) 188 | 189 | export const matchObject = (expected) => 190 | ValueMatcher((value) => { 191 | if (expected === undefined) { 192 | if (typeof value === 'object') { 193 | return { 194 | matched: true, 195 | value, 196 | result: {}, 197 | } 198 | } else { 199 | return unmatched 200 | } 201 | } 202 | let restKey 203 | const values = {} 204 | const results = {} 205 | const matchedByKey = {} 206 | const unmatchedKeys = [] 207 | for (const key in expected) { 208 | const matcher = asMatcher(expected[key]) 209 | if (matcher === rest) { 210 | restKey = key 211 | continue 212 | } 213 | 214 | if (value !== undefined) { 215 | results[key] = matcher(value[key]) 216 | values[key] = results[key].value 217 | } 218 | if (!results?.[key]?.matched ?? false) { 219 | unmatchedKeys.push(key) 220 | continue 221 | } 222 | matchedByKey[key] = true 223 | } 224 | const restValue = {} 225 | for (const key in value) { 226 | if (matchedByKey[key]) { 227 | continue 228 | } 229 | if (restKey) { 230 | restValue[key] = value[key] 231 | } else { 232 | unmatchedKeys.push(key) 233 | } 234 | } 235 | if (unmatchedKeys.length !== 0) { 236 | unmatchedKeys.sort() 237 | return { 238 | matched: false, 239 | expectedKeys: Object.keys(expected).sort(), 240 | restKey, 241 | matchedKeys: Object.keys(matchedByKey).sort(), 242 | unmatchedKeys, 243 | } 244 | } 245 | if (restKey) { 246 | results[restKey] = { matched: true, value: restValue } 247 | values[restKey] = results[restKey].value 248 | } 249 | return { 250 | matched: true, 251 | value: values, 252 | result: results, 253 | } 254 | }) 255 | 256 | export const matchPredicate = (predicate) => 257 | ValueMatcher((value) => 258 | predicate(value) 259 | ? { 260 | matched: true, 261 | value, 262 | } 263 | : unmatched 264 | ) 265 | 266 | export const equals = (expected) => 267 | matchPredicate((value) => expected === value) 268 | 269 | export const matchBoolean = (expected) => 270 | matchPredicate( 271 | (value) => 272 | (expected === undefined && typeof value === 'boolean') || 273 | expected === value 274 | ) 275 | 276 | export const matchNumber = (expected) => 277 | matchPredicate( 278 | (value) => 279 | (expected === undefined && typeof value === 'number') || 280 | expected === value 281 | ) 282 | 283 | export const matchBigInt = (expected) => 284 | matchPredicate( 285 | (value) => 286 | (expected === undefined && typeof value === 'bigint') || 287 | expected === value 288 | ) 289 | 290 | export const matchString = (expected) => 291 | matchPredicate( 292 | (value) => 293 | (expected === undefined && 294 | (typeof value === 'string' || value instanceof String)) || 295 | String(expected) === String(value) 296 | ) 297 | 298 | export const any = matchPredicate(() => true) 299 | 300 | export const between = (lower, upper) => 301 | matchPredicate( 302 | (value) => typeof value === 'number' && lower <= value && value < upper 303 | ) 304 | 305 | export const greaterThan = (expected) => 306 | matchPredicate((value) => typeof value === 'number' && expected < value) 307 | 308 | export const greaterThanEquals = (expected) => 309 | matchPredicate((value) => typeof value === 'number' && expected <= value) 310 | 311 | export const lessThan = (expected) => 312 | matchPredicate((value) => typeof value === 'number' && expected > value) 313 | 314 | export const lessThanEquals = (expected) => 315 | matchPredicate((value) => typeof value === 'number' && expected >= value) 316 | 317 | export const matchRegExp = (expected) => 318 | ValueMatcher((value) => { 319 | if (typeof value === 'string' || value instanceof String) { 320 | const matchedRegExp = value.match(expected) 321 | if (matchedRegExp) { 322 | return { 323 | matched: true, 324 | value: value, 325 | matchedRegExp, 326 | } 327 | } 328 | return { 329 | matched: false, 330 | expected, 331 | matchedRegExp, 332 | } 333 | } 334 | return { 335 | matched: false, 336 | expected, 337 | typeofValue: typeof value, 338 | } 339 | }) 340 | 341 | export const not = (matchable) => 342 | IteratorMatcher((iterator) => { 343 | const matcher = asMatcher(matchable) 344 | const time = iterator.now 345 | const result = matcher(iterator) 346 | iterator.jump(time) 347 | if (!result.matched) { 348 | const { value, done } = iterator.next() 349 | if (done) { 350 | return unmatched 351 | } 352 | return { 353 | matched: true, 354 | value, 355 | } 356 | } 357 | return unmatched 358 | }) 359 | 360 | export const defined = not(equals(undefined)) 361 | 362 | export const oneOf = (...matchables) => 363 | IteratorMatcher((iterator) => { 364 | const time = iterator.now 365 | for (const matchable of matchables) { 366 | const matcher = asMatcher(matchable) 367 | const result = matcher(iterator) 368 | if (result.matched) { 369 | return result 370 | } 371 | iterator.jump(time) 372 | } 373 | return unmatched 374 | }) 375 | 376 | export const allOf = (...matchables) => 377 | IteratorMatcher((iterator) => { 378 | const time = iterator.now 379 | const end = [] 380 | const results = [] 381 | for (const matchable of matchables) { 382 | const matcher = asMatcher(matchable) 383 | iterator.jump(time) 384 | const result = matcher(iterator) 385 | end.push(iterator.now) 386 | results.push(result) 387 | if (!result.matched) { 388 | iterator.jump(time) 389 | return { 390 | matched: false, 391 | expected: matchables, 392 | failed: result, 393 | } 394 | } 395 | } 396 | 397 | if (end.some((c) => c !== end[0])) { 398 | iterator.jump(time) 399 | return unmatched 400 | } 401 | const values = [] 402 | iterator.jump(time) 403 | for (let i = time; i < end[0]; i++) { 404 | values.push(iterator.next().value) 405 | } 406 | 407 | return { 408 | matched: true, 409 | value: values.length <= 1 ? values[0] : values, 410 | result: results, 411 | } 412 | }) 413 | 414 | export const matchProp = (expected) => 415 | matchPredicate((value) => expected in value) 416 | 417 | export const empty = matchPredicate( 418 | (value) => 419 | value === '' || 420 | (Array.isArray(value) && value.length === 0) || 421 | Object.keys(value).length === 0 422 | ) 423 | 424 | export const when = (matchable, ...valueMappers) => { 425 | const matcher = asMatcher(matchable) 426 | if (valueMappers.length === 0) { 427 | return matcher 428 | } 429 | 430 | const guards = valueMappers.slice(0, -1) 431 | const guardMatcher = mapResultMatcher((result) => { 432 | if (result.matched) { 433 | const allGuardsPassed = guards.every((guard) => 434 | guard(result.value, result) 435 | ) 436 | if (allGuardsPassed) { 437 | return result 438 | } 439 | } 440 | return unmatched 441 | })(matcher) 442 | 443 | const valueMapper = valueMappers[valueMappers.length - 1] 444 | return mapMatcher(valueMapper)(guardMatcher) 445 | } 446 | 447 | export const otherwise = (...mapperables) => when(any, ...mapperables) 448 | -------------------------------------------------------------------------------- /matchers/matchAllOf.spec.js: -------------------------------------------------------------------------------- 1 | import { expectMatched, expectUnmatched } from './test-utils.js' 2 | 3 | import { allOf, matchProp } from '../index.js' 4 | 5 | describe('allOf', () => { 6 | test('matches any if no expected matchers', () => { 7 | const matcher = allOf() 8 | const result = matcher(undefined) 9 | expectMatched(result) 10 | expect(result.value).toEqual(undefined) 11 | }) 12 | 13 | test('matched both RegExp', () => { 14 | const matcher = allOf(/hello/, /world/) 15 | const result = matcher('hello world') 16 | expectMatched(result) 17 | expect(result.value).toEqual('hello world') 18 | }) 19 | 20 | test('matched string with prop', () => { 21 | const matcher = allOf(/Eve/, matchProp('evil')) 22 | const input = new String('Eve') 23 | input.evil = true 24 | const result = matcher(input) 25 | expectMatched(result) 26 | expect(result.value).toEqual(input) 27 | }) 28 | 29 | test('access nested matched result fields', () => { 30 | const matcher = allOf(/^(?hello|hi)/, /(?\d+)$/) 31 | const result = matcher('hello 42') 32 | expectMatched(result) 33 | const { 34 | result: [ 35 | { 36 | matchedRegExp: { 37 | groups: { greeting }, 38 | }, 39 | }, 40 | { 41 | matchedRegExp: { 42 | groups: { id }, 43 | }, 44 | }, 45 | ], 46 | } = result 47 | expect(greeting).toBe('hello') 48 | expect(id).toBe('42') 49 | }) 50 | 51 | test('unmatched when one RegExp is unmatched', () => { 52 | const matcher = allOf(/hello/, /world/) 53 | const result = matcher('hello bob') 54 | expectUnmatched(result) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /matchers/matchArray.spec.js: -------------------------------------------------------------------------------- 1 | import { expectMatched, expectUnmatched } from './test-utils.js' 2 | 3 | import { matchArray, maybe, group, some, rest, any } from './index.js' 4 | 5 | describe('matchArray', () => { 6 | test('empty expected matches any array', () => { 7 | const matcher = matchArray() 8 | const result = matcher([1, 2, 3]) 9 | expectMatched(result) 10 | expect(result.value).toEqual([1, 2, 3]) 11 | }) 12 | 13 | test('empty expected does not match undefined', () => { 14 | const matcher = matchArray() 15 | const result = matcher() 16 | expectUnmatched(result) 17 | }) 18 | 19 | test('matched identical literal array', () => { 20 | const matcher = matchArray([1, 2, 3]) 21 | const result = matcher([1, 2, 3]) 22 | expectMatched(result) 23 | expect(result.value).toEqual([1, 2, 3]) 24 | }) 25 | 26 | test('holes in expected are undefined', () => { 27 | const matcher = matchArray([1, , 3]) 28 | const result = matcher([1, , 3]) 29 | expectMatched(result) 30 | expect(result.value).toEqual([1, undefined, 3]) 31 | expectUnmatched(matcher([1, 2, 3])) 32 | }) 33 | 34 | test('holes in value match undefined', () => { 35 | const matcher = matchArray([1, undefined, 3]) 36 | const result = matcher([1, , 3]) 37 | expectMatched(result) 38 | expect(result.value).toEqual([1, undefined, 3]) 39 | expectUnmatched(matcher([1, 2, 3])) 40 | }) 41 | 42 | test('unmatched when expected more elements', () => { 43 | const matcher = matchArray([1, 2, 3]) 44 | const result = matcher([1, 2]) 45 | expectUnmatched(result) 46 | }) 47 | 48 | test('unmatched on non-array value', () => { 49 | const matcher = matchArray() 50 | const result = matcher('alice') 51 | expectUnmatched(result) 52 | }) 53 | 54 | test('unmatched when more elements than expected', () => { 55 | const matcher = matchArray([1, 2, 3]) 56 | const result = matcher([1, 2, 3, 4]) 57 | expectUnmatched(result) 58 | }) 59 | 60 | test('access nested matched result fields', () => { 61 | const matcher = matchArray([/^hello (?\d+)$/]) 62 | const result = matcher(['hello 42']) 63 | expectMatched(result) 64 | const { 65 | result: [ 66 | { 67 | matchedRegExp: { 68 | groups: { id }, 69 | }, 70 | }, 71 | ], 72 | } = result 73 | expect(id).toBe('42') 74 | }) 75 | 76 | test('rest matches remaining element', () => { 77 | const matcher = matchArray([1, rest]) 78 | const result = matcher([1, 2, 3]) 79 | expectMatched(result) 80 | expect(result.value).toEqual([1, [2, 3]]) 81 | expect(result.result[1].value).toEqual([2, 3]) 82 | }) 83 | 84 | test('rest matches remaining element even if already complete match', () => { 85 | const matcher = matchArray([1, 2, 3, rest]) 86 | const result = matcher([1, 2, 3]) 87 | expectMatched(result) 88 | expect(result.value).toEqual([1, 2, 3, []]) 89 | expect(result.result[3].value).toEqual([]) 90 | }) 91 | 92 | test('empty expected matches any iterator', () => { 93 | const matcher = matchArray() 94 | function* numbers() { 95 | yield 1 96 | yield 2 97 | yield 3 98 | } 99 | const result = matcher(numbers()) 100 | expectMatched(result) 101 | expect(result.value).toEqual([1, 2, 3]) 102 | }) 103 | 104 | test('matched iterator with same elements', () => { 105 | const matcher = matchArray([1, 2, 3]) 106 | function* numbers() { 107 | yield 1 108 | yield 2 109 | yield 3 110 | } 111 | const result = matcher(numbers()) 112 | expectMatched(result) 113 | expect(result.value).toEqual([1, 2, 3]) 114 | }) 115 | 116 | test('rest matches remaining iterator', () => { 117 | const matcher = matchArray([1, rest]) 118 | function* numbers() { 119 | yield 1 120 | yield 2 121 | yield 3 122 | } 123 | const result = matcher(numbers()) 124 | expectMatched(result) 125 | expect(result.value).toEqual([1, [2, 3]]) 126 | }) 127 | 128 | test('rest matches remaining iterator even if already complete match', () => { 129 | const matcher = matchArray([1, 2, 3, rest]) 130 | function* numbers() { 131 | yield 1 132 | yield 2 133 | yield 3 134 | } 135 | const result = matcher(numbers()) 136 | expectMatched(result) 137 | expect(result.value).toEqual([1, 2, 3, []]) 138 | }) 139 | 140 | test('unmatched iterator when expected more elements', () => { 141 | const matcher = matchArray([1, 2, 3]) 142 | function* numbers() { 143 | yield 1 144 | yield 2 145 | } 146 | const result = matcher(numbers()) 147 | expectUnmatched(result) 148 | }) 149 | 150 | test('unmatched iterator when more elements than expected', () => { 151 | const matcher = matchArray([1, 2, 3]) 152 | function* numbers() { 153 | yield 1 154 | yield 2 155 | yield 3 156 | yield 4 157 | yield 5 158 | } 159 | const result = matcher(numbers()) 160 | expectUnmatched(result) 161 | }) 162 | 163 | test('only reads iterator one passed number of expected elements', () => { 164 | const matcher = matchArray([1, 2, 3]) 165 | function* numbers() { 166 | yield 1 167 | yield 2 168 | yield 3 169 | yield 4 170 | yield 5 171 | yield 6 172 | } 173 | const iterable = numbers() 174 | matcher(iterable) 175 | expect([...iterable]).toEqual([5, 6]) 176 | }) 177 | 178 | test('matched maybe of single element', () => { 179 | const matcher = matchArray([maybe(1)]) 180 | expectMatched(matcher([1])) 181 | expectMatched(matcher([])) 182 | }) 183 | 184 | test('matched maybe is present in result', () => { 185 | const matcher = matchArray([maybe(1)]) 186 | const result = matcher([1]) 187 | expect(result).toEqual({ 188 | matched: true, 189 | value: [1], 190 | result: [ 191 | { 192 | matched: true, 193 | value: 1, 194 | }, 195 | ], 196 | }) 197 | }) 198 | 199 | test('matched maybe is present as undefined when element is not present', () => { 200 | const matcher = matchArray([maybe(1)]) 201 | const result = matcher([]) 202 | expect(result).toEqual({ 203 | matched: true, 204 | value: [undefined], 205 | result: [ 206 | { 207 | matched: true, 208 | value: undefined, 209 | }, 210 | ], 211 | }) 212 | }) 213 | 214 | test('matched maybe before other elements', () => { 215 | const matcher = matchArray([maybe(1), 2]) 216 | expectMatched(matcher([1, 2])) 217 | expectMatched(matcher([2])) 218 | }) 219 | 220 | test('matched maybe after other elements', () => { 221 | const matcher = matchArray([1, maybe(2)]) 222 | expectMatched(matcher([1, 2])) 223 | expectMatched(matcher([1])) 224 | }) 225 | 226 | test('matched group of matching elements', () => { 227 | const matcher = matchArray([group(1, 2)]) 228 | expectMatched(matcher([1, 2])) 229 | }) 230 | 231 | test('unmatched when not enough elements match group', () => { 232 | const matcher = matchArray([group(1, 2)]) 233 | expectUnmatched(matcher([])) 234 | expectUnmatched(matcher([1])) 235 | }) 236 | 237 | test('matched group of matching elements before other elements', () => { 238 | const matcher = matchArray([group(1, 2), 3]) 239 | expectMatched(matcher([1, 2, 3])) 240 | }) 241 | 242 | test('matched group of matching elements after other elements', () => { 243 | const matcher = matchArray([1, group(2, 3)]) 244 | expectMatched(matcher([1, 2, 3])) 245 | }) 246 | 247 | test('matched some of matching elements', () => { 248 | const matcher = matchArray([some(1)]) 249 | expectMatched(matcher([1])) 250 | expectMatched(matcher([1, 1])) 251 | }) 252 | 253 | test('unmatched when no elements match some', () => { 254 | const matcher = matchArray([some(1)]) 255 | expectUnmatched(matcher([])) 256 | expectUnmatched(matcher([2])) 257 | }) 258 | 259 | test('unmatched extra elements after some', () => { 260 | const matcher = matchArray([some(1)]) 261 | expectUnmatched(matcher([1, 2])) 262 | }) 263 | 264 | test('matched some of matching elements before other elements', () => { 265 | const matcher = matchArray([some(1), 2]) 266 | expectMatched(matcher([1, 2])) 267 | expectMatched(matcher([1, 1, 2])) 268 | }) 269 | 270 | test('matched some of matching elements after other elements', () => { 271 | const matcher = matchArray([1, some(2)]) 272 | expectMatched(matcher([1, 2])) 273 | expectMatched(matcher([1, 2, 2])) 274 | }) 275 | 276 | test('maybe of group', () => { 277 | const matcher = matchArray([maybe(group(1, 2)), 3]) 278 | 279 | expectMatched(matcher([1, 2, 3])) 280 | expectMatched(matcher([3])) 281 | expectUnmatched(matcher([])) 282 | expectUnmatched(matcher([1, 2])) 283 | }) 284 | 285 | test('maybe of some', () => { 286 | const matcher = matchArray([maybe(some(1)), 3]) 287 | 288 | expectMatched(matcher([3])) 289 | expectMatched(matcher([1, 3])) 290 | expectMatched(matcher([1, 1, 3])) 291 | expectUnmatched(matcher([])) 292 | expectUnmatched(matcher([1])) 293 | }) 294 | 295 | test('some of group', () => { 296 | const matcher = matchArray([some(group(1, 2)), 3]) 297 | 298 | expectMatched(matcher([1, 2, 3])) 299 | expectMatched(matcher([1, 2, 1, 2, 3])) 300 | expectUnmatched(matcher([])) 301 | expectUnmatched(matcher([1, 2])) 302 | expectUnmatched(matcher([3])) 303 | }) 304 | 305 | test('some of maybe should throw', () => { 306 | const matcher = matchArray([some(maybe(1))]) 307 | 308 | expect(() => matcher([])).toThrow('infinite loop') 309 | }) 310 | 311 | test('group of maybe', () => { 312 | const matcher = matchArray([group(maybe(1), 2), 3]) 313 | 314 | expectMatched(matcher([2, 3])) 315 | expectMatched(matcher([1, 2, 3])) 316 | expectUnmatched(matcher([])) 317 | expectUnmatched(matcher([1, 2])) 318 | expectUnmatched(matcher([3])) 319 | }) 320 | 321 | test('group of some', () => { 322 | const matcher = matchArray([group(some(1), 2), 3]) 323 | 324 | expectMatched(matcher([1, 2, 3])) 325 | expectMatched(matcher([1, 1, 2, 3])) 326 | expectUnmatched(matcher([])) 327 | expectUnmatched(matcher([2, 3])) 328 | expectUnmatched(matcher([1, 2])) 329 | expectUnmatched(matcher([3])) 330 | }) 331 | }) 332 | -------------------------------------------------------------------------------- /matchers/matchObject.spec.js: -------------------------------------------------------------------------------- 1 | import { expectMatched, expectUnmatched } from './test-utils.js' 2 | 3 | import { matchObject, oneOf, not, defined, rest } from './index.js' 4 | 5 | describe('matchObject', () => { 6 | test('empty expected matches any object', () => { 7 | const matcher = matchObject() 8 | const result = matcher({ x: 1, y: 2, z: 3 }) 9 | expectMatched(result) 10 | expect(result.value).toEqual({ 11 | x: 1, 12 | y: 2, 13 | z: 3, 14 | }) 15 | }) 16 | 17 | test('matched identical literal object', () => { 18 | const matcher = matchObject({ x: 1, y: 2, z: 3 }) 19 | const result = matcher({ x: 1, y: 2, z: 3 }) 20 | expectMatched(result) 21 | expect(result.value).toEqual({ 22 | x: 1, 23 | y: 2, 24 | z: 3, 25 | }) 26 | }) 27 | 28 | test('empty expected does not match undefined', () => { 29 | const matcher = matchObject() 30 | const result = matcher() 31 | expectUnmatched(result) 32 | }) 33 | 34 | test('access nested matched result fields', () => { 35 | const matcher = matchObject({ x: /^hello (?\d+)$/ }) 36 | const result = matcher({ x: 'hello 42' }) 37 | expectMatched(result) 38 | const { 39 | result: { 40 | x: { 41 | matchedRegExp: { 42 | groups: { id }, 43 | }, 44 | }, 45 | }, 46 | } = result 47 | expect(id).toBe('42') 48 | }) 49 | 50 | test('unmatched empty object with expected field', () => { 51 | const matcher = matchObject({ x: defined }) 52 | const result = matcher({}) 53 | expectUnmatched(result) 54 | }) 55 | 56 | test('rest matcher collect remaining fields', () => { 57 | const matcher = matchObject({ x: 1, rest }) 58 | const result = matcher({ x: 1, y: 2, z: 3 }) 59 | expectMatched(result) 60 | expect(result.value).toEqual({ 61 | x: 1, 62 | rest: { 63 | y: 2, 64 | z: 3, 65 | }, 66 | }) 67 | expect(result.result.rest.value).toEqual({ 68 | y: 2, 69 | z: 3, 70 | }) 71 | }) 72 | 73 | test('rest matcher collect remaining fields even if already complete match', () => { 74 | const matcher = matchObject({ x: 1, y: 2, z: 3, rest }) 75 | const result = matcher({ x: 1, y: 2, z: 3 }) 76 | expectMatched(result) 77 | expect(result.value).toEqual({ 78 | x: 1, 79 | y: 2, 80 | z: 3, 81 | rest: {}, 82 | }) 83 | expect(result.result.rest.value).toEqual({}) 84 | }) 85 | 86 | test('composes with oneOf', () => { 87 | const matcher = matchObject({ x: oneOf(1, undefined), y: 2 }) 88 | expectMatched(matcher({ y: 2 })) 89 | expectMatched(matcher({ x: 1, y: 2 })) 90 | expectUnmatched(matcher({ x: 3, y: 2 })) 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /matchers/matchOneOf.spec.js: -------------------------------------------------------------------------------- 1 | import { expectMatched, expectUnmatched } from './test-utils.js' 2 | 3 | import { matchArray, oneOf, group } from '../index.js' 4 | 5 | describe('oneOf', () => { 6 | test('unmatched if no expected matchers', () => { 7 | const matcher = oneOf() 8 | expectUnmatched(matcher(undefined)) 9 | expectUnmatched(matcher('alice')) 10 | }) 11 | 12 | test('one of group', () => { 13 | const matcher = matchArray([ 14 | oneOf(group('alice', 'bob'), group('fred', 'sally')), 15 | ]) 16 | expectMatched(matcher(['alice', 'bob'])) 17 | expectMatched(matcher(['fred', 'sally'])) 18 | 19 | expectUnmatched(matcher([])) 20 | expectUnmatched(matcher(['alice'])) 21 | expectUnmatched(matcher(['bob'])) 22 | expectUnmatched(matcher(['fred'])) 23 | expectUnmatched(matcher(['sally'])) 24 | expectUnmatched(matcher(['alice', 'sally'])) 25 | expectUnmatched(matcher(['fred', 'bob'])) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /matchers/matchRegExp.spec.js: -------------------------------------------------------------------------------- 1 | import { expectMatched, expectUnmatched } from './test-utils.js' 2 | 3 | import { matchRegExp } from './index.js' 4 | 5 | describe('matchRegExp', () => { 6 | test('matched exact RegExp', () => { 7 | const matcher = matchRegExp(/^Alice$/) 8 | const result = matcher('Alice') 9 | expectMatched(result) 10 | expect(result.value).toBe('Alice') 11 | }) 12 | 13 | test('matchedRegExp on matched result has named groups', () => { 14 | const matcher = matchRegExp(/^(?.+) (?.+)$/) 15 | const result = matcher('Hello Alice') 16 | expectMatched(result) 17 | const { 18 | matchedRegExp: { groups }, 19 | } = result 20 | expect(groups).toEqual({ 21 | greeting: 'Hello', 22 | name: 'Alice', 23 | }) 24 | }) 25 | 26 | test('matchedRegExp on matched result', () => { 27 | const matcher = matchRegExp(/^(.+) (.+)$/) 28 | const result = matcher('Hello Alice') 29 | expectMatched(result) 30 | const { 31 | matchedRegExp: [, greeting, name], 32 | } = result 33 | expect({ greeting, name }).toEqual({ greeting: 'Hello', name: 'Alice' }) 34 | }) 35 | 36 | test('unmatched to unmatched RegExp', () => { 37 | const matcher = matchRegExp(/^Alice$/) 38 | const result = matcher('Bob') 39 | expectUnmatched(result) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /matchers/matchString.spec.js: -------------------------------------------------------------------------------- 1 | import { expectMatched, expectUnmatched } from './test-utils.js' 2 | 3 | import { matchString } from './index.js' 4 | 5 | describe('matchString', () => { 6 | test('matched exact string', () => { 7 | const matcher = matchString('Alice') 8 | const result = matcher('Alice') 9 | expectMatched(result) 10 | expect(result.value).toBe('Alice') 11 | }) 12 | 13 | test('matched when value is new String', () => { 14 | const matcher = matchString('Alice') 15 | const value = new String('Alice') 16 | const result = matcher(value) 17 | expectMatched(result) 18 | expect(result.value).toBe(value) 19 | }) 20 | 21 | test('matched expected is new String', () => { 22 | const matcher = matchString(new String('Alice')) 23 | const result = matcher('Alice') 24 | expectMatched(result) 25 | expect(result.value).toBe('Alice') 26 | }) 27 | 28 | test('unmatched to different string', () => { 29 | const matcher = matchString('Ailce') 30 | const result = matcher('Bob') 31 | expectUnmatched(result) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /matchers/test-utils.js: -------------------------------------------------------------------------------- 1 | export function expectMatched(result) { 2 | if (!result.matched) { 3 | console.dir(result) 4 | throw new Error('expected result to be matched but was unmatched') 5 | } 6 | } 7 | export function expectUnmatched(result) { 8 | if (result.matched) { 9 | console.dir(result) 10 | throw new Error('expected result to be unmatched but was matched') 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /matchers/test-utils.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { expectMatched } from './test-utils.js' 4 | 5 | describe('test utils', () => { 6 | describe('expectedMatch', () => { 7 | test('does nothing for matched result', () => { 8 | const result = { 9 | matched: true, 10 | } 11 | expectMatched(result) 12 | }) 13 | 14 | test('console.dir and throws on unmatched result', () => { 15 | const result = { 16 | matched: false, 17 | someContext: 'some context value', 18 | } 19 | const dir = jest.spyOn(global.console, 'dir').mockImplementation() 20 | expect(() => expectMatched(result)).toThrow() 21 | expect(dir).toHaveBeenNthCalledWith(1, result) 22 | }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "patcom", 3 | "version": "1.1.0", 4 | "description": "Pattern match by combining simpler patterns", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/concept-not-found/patcom.git" 8 | }, 9 | "bugs": { 10 | "url": "https://github.com/concept-not-found/patcom/issues" 11 | }, 12 | "homepage": "https://github.com/concept-not-found/patcom", 13 | "type": "module", 14 | "main": "index.js", 15 | "exports": { 16 | ".": "./index.js", 17 | "./tc39-proposal-pattern-matching": "./tc39-proposal-pattern-matching/index.js" 18 | }, 19 | "scripts": { 20 | "build": "build() { esbuild $1 | node --input-type=module > dist/comparison.html; }; build", 21 | "start": "nodemon --exec 'npm run build' ./tc39-proposal-pattern-matching/comparison.jsx", 22 | "test": "NODE_OPTIONS='--experimental-vm-modules --no-warnings' jest" 23 | }, 24 | "keywords": [ 25 | "pattern matching", 26 | "functional", 27 | "pattern", 28 | "combinator" 29 | ], 30 | "author": "Ronald Chen ", 31 | "license": "MIT", 32 | "devDependencies": { 33 | "esbuild": "0.14.23", 34 | "jest": "27.5.1", 35 | "nodemon": "2.0.15", 36 | "prettier": "2.5.1", 37 | "react": "17.0.2", 38 | "react-dom": "17.0.2" 39 | }, 40 | "jest": { 41 | "transform": {}, 42 | "clearMocks": true, 43 | "resetMocks": true, 44 | "restoreMocks": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /samples.spec.js: -------------------------------------------------------------------------------- 1 | import { expectUnmatched } from './matchers/test-utils.js' 2 | import { 3 | asInternalIterator, 4 | group, 5 | some, 6 | maybe, 7 | matchArray, 8 | matchObject, 9 | match, 10 | when, 11 | otherwise, 12 | defined, 13 | rest, 14 | } from './index.js' 15 | 16 | describe('samples', () => { 17 | describe('discriminating unions', () => { 18 | const greeter = (person) => 19 | match(person)( 20 | when( 21 | { role: 'teacher', surname: defined }, 22 | ({ surname }) => `Good morning ${surname} sensei.` 23 | ), 24 | when({ role: 'student' }, () => 'Hello fellow student.'), 25 | otherwise(() => 'STRANGER DANGER') 26 | ) 27 | 28 | test('greet teacher', () => { 29 | const person = { 30 | role: 'teacher', 31 | surname: 'Wong', 32 | } 33 | expect(greeter(person)).toBe('Good morning Wong sensei.') 34 | }) 35 | 36 | test('greet student', () => { 37 | const person = { 38 | role: 'student', 39 | } 40 | expect(greeter(person)).toBe('Hello fellow student.') 41 | }) 42 | 43 | test('otherwise alarm', () => { 44 | const person = { 45 | role: 'robot', 46 | } 47 | expect(greeter(person)).toBe('STRANGER DANGER') 48 | }) 49 | }) 50 | 51 | describe('access nested rest', () => { 52 | const matcher = when( 53 | { 54 | headers: [ 55 | { 56 | name: 'cookie', 57 | value: defined, 58 | }, 59 | rest, 60 | ], 61 | rest, 62 | }, 63 | ({ 64 | headers: [{ value: cookieValue }, restOfHeaders], 65 | rest: restOfResponse, 66 | }) => ({ cookieValue, restOfHeaders, restOfResponse }) 67 | ) 68 | 69 | test('extra headers and response values are accessible', () => { 70 | const result = match({ 71 | status: 200, 72 | headers: [ 73 | { 74 | name: 'cookie', 75 | value: 'om', 76 | }, 77 | { 78 | name: 'accept', 79 | value: 'everybody', 80 | }, 81 | ], 82 | })(matcher) 83 | expect(result).toEqual({ 84 | cookieValue: 'om', 85 | restOfHeaders: [{ name: 'accept', value: 'everybody' }], 86 | restOfResponse: { status: 200 }, 87 | }) 88 | }) 89 | }) 90 | 91 | describe('matchArray example', () => { 92 | test('rest', () => { 93 | const matcher = matchArray([42, 'alice', rest]) 94 | 95 | expect(matcher([42, 'alice'])).toEqual({ 96 | matched: true, 97 | value: [42, 'alice', []], 98 | result: [ 99 | { matched: true, value: 42 }, 100 | { matched: true, value: 'alice' }, 101 | { matched: true, value: [] }, 102 | ], 103 | }) 104 | expect(matcher([42, 'alice', true, 69])).toEqual({ 105 | matched: true, 106 | value: [42, 'alice', [true, 69]], 107 | result: [ 108 | { matched: true, value: 42 }, 109 | { matched: true, value: 'alice' }, 110 | { matched: true, value: [true, 69] }, 111 | ], 112 | }) 113 | 114 | expectUnmatched(matcher(['alice', 42])) 115 | expectUnmatched(matcher([])) 116 | expectUnmatched(matcher([42])) 117 | }) 118 | }) 119 | 120 | describe('matchObject example', () => { 121 | test('rest', () => { 122 | const matcher = matchObject({ x: 42, y: 'alice', rest }) 123 | 124 | expect(matcher({ x: 42, y: 'alice' })).toEqual({ 125 | matched: true, 126 | value: { x: 42, y: 'alice', rest: {} }, 127 | result: { 128 | x: { matched: true, value: 42 }, 129 | y: { matched: true, value: 'alice' }, 130 | rest: { matched: true, value: {} }, 131 | }, 132 | }) 133 | expect(matcher({ x: 42, y: 'alice', z: true, aa: 69 })).toEqual({ 134 | matched: true, 135 | value: { 136 | x: 42, 137 | y: 'alice', 138 | rest: { z: true, aa: 69 }, 139 | }, 140 | result: { 141 | x: { matched: true, value: 42 }, 142 | y: { matched: true, value: 'alice' }, 143 | rest: { matched: true, value: { z: true, aa: 69 } }, 144 | }, 145 | }) 146 | 147 | expectUnmatched(matcher({})) 148 | expectUnmatched(matcher({ x: 42 })) 149 | }) 150 | test('custom rest key', () => { 151 | const matcher = matchObject({ x: 42, y: 'alice', customRestKey: rest }) 152 | 153 | expect(matcher({ x: 42, y: 'alice', z: true })).toEqual({ 154 | matched: true, 155 | value: { 156 | x: 42, 157 | y: 'alice', 158 | customRestKey: { z: true }, 159 | }, 160 | result: { 161 | x: { matched: true, value: 42 }, 162 | y: { matched: true, value: 'alice' }, 163 | customRestKey: { matched: true, value: { z: true } }, 164 | }, 165 | }) 166 | }) 167 | }) 168 | 169 | describe('maybe example', () => { 170 | test('only maybe', () => { 171 | const matcher = matchArray([maybe('alice'), 'bob']) 172 | 173 | expect(matcher(['alice', 'bob'])).toEqual({ 174 | matched: true, 175 | value: ['alice', 'bob'], 176 | result: [ 177 | { 178 | matched: true, 179 | value: 'alice', 180 | }, 181 | { matched: true, value: 'bob' }, 182 | ], 183 | }) 184 | expect(matcher(['bob'])).toEqual({ 185 | matched: true, 186 | value: [undefined, 'bob'], 187 | result: [ 188 | { matched: true, value: undefined }, 189 | { matched: true, value: 'bob' }, 190 | ], 191 | }) 192 | 193 | expectUnmatched(matcher(['eve', 'bob'])) 194 | expectUnmatched(matcher(['eve'])) 195 | }) 196 | test('maybe of group', () => { 197 | const matcher = matchArray([maybe(group('alice', 'fred')), 'bob']) 198 | 199 | expect(matcher(['alice', 'fred', 'bob'])).toEqual({ 200 | matched: true, 201 | value: [['alice', 'fred'], 'bob'], 202 | result: [ 203 | { 204 | matched: true, 205 | value: ['alice', 'fred'], 206 | result: [ 207 | { matched: true, value: 'alice' }, 208 | { matched: true, value: 'fred' }, 209 | ], 210 | }, 211 | { matched: true, value: 'bob' }, 212 | ], 213 | }) 214 | expect(matcher(['bob'])).toEqual({ 215 | matched: true, 216 | value: [undefined, 'bob'], 217 | result: [ 218 | { matched: true, value: undefined }, 219 | { matched: true, value: 'bob' }, 220 | ], 221 | }) 222 | }) 223 | 224 | test('maybe of some', () => { 225 | const matcher = matchArray([maybe(some('alice')), 'bob']) 226 | 227 | expect(matcher(['alice', 'bob'])).toEqual({ 228 | matched: true, 229 | value: [['alice'], 'bob'], 230 | result: [ 231 | { 232 | matched: true, 233 | value: ['alice'], 234 | result: [{ matched: true, value: 'alice' }], 235 | }, 236 | { matched: true, value: 'bob' }, 237 | ], 238 | }) 239 | expect(matcher(['alice', 'alice', 'bob'])).toEqual({ 240 | matched: true, 241 | value: [['alice', 'alice'], 'bob'], 242 | result: [ 243 | { 244 | matched: true, 245 | value: ['alice', 'alice'], 246 | result: [ 247 | { matched: true, value: 'alice' }, 248 | { matched: true, value: 'alice' }, 249 | ], 250 | }, 251 | { matched: true, value: 'bob' }, 252 | ], 253 | }) 254 | expect(matcher(['bob'])).toEqual({ 255 | matched: true, 256 | value: [undefined, 'bob'], 257 | result: [ 258 | { matched: true, value: undefined }, 259 | { matched: true, value: 'bob' }, 260 | ], 261 | }) 262 | }) 263 | }) 264 | 265 | describe('some example', () => { 266 | test('just some', () => { 267 | const matcher = matchArray([some('alice'), 'bob']) 268 | 269 | expect(matcher(['alice', 'alice', 'bob'])).toEqual({ 270 | matched: true, 271 | value: [['alice', 'alice'], 'bob'], 272 | result: [ 273 | { 274 | matched: true, 275 | value: ['alice', 'alice'], 276 | result: [ 277 | { matched: true, value: 'alice' }, 278 | { matched: true, value: 'alice' }, 279 | ], 280 | }, 281 | { matched: true, value: 'bob' }, 282 | ], 283 | }) 284 | 285 | expectUnmatched(matcher(['eve', 'bob'])) 286 | expectUnmatched(matcher(['bob'])) 287 | }) 288 | test('some of group', () => { 289 | const matcher = matchArray([some(group('alice', 'fred')), 'bob']) 290 | 291 | expect(matcher(['alice', 'fred', 'bob'])).toEqual({ 292 | matched: true, 293 | value: [[['alice', 'fred']], 'bob'], 294 | result: [ 295 | { 296 | matched: true, 297 | value: [['alice', 'fred']], 298 | result: [ 299 | { 300 | matched: true, 301 | value: ['alice', 'fred'], 302 | result: [ 303 | { matched: true, value: 'alice' }, 304 | { matched: true, value: 'fred' }, 305 | ], 306 | }, 307 | ], 308 | }, 309 | { matched: true, value: 'bob' }, 310 | ], 311 | }) 312 | expect(matcher(['alice', 'fred', 'alice', 'fred', 'bob'])).toEqual({ 313 | matched: true, 314 | value: [ 315 | [ 316 | ['alice', 'fred'], 317 | ['alice', 'fred'], 318 | ], 319 | 'bob', 320 | ], 321 | result: [ 322 | { 323 | matched: true, 324 | value: [ 325 | ['alice', 'fred'], 326 | ['alice', 'fred'], 327 | ], 328 | result: [ 329 | { 330 | matched: true, 331 | value: ['alice', 'fred'], 332 | result: [ 333 | { matched: true, value: 'alice' }, 334 | { matched: true, value: 'fred' }, 335 | ], 336 | }, 337 | { 338 | matched: true, 339 | value: ['alice', 'fred'], 340 | result: [ 341 | { matched: true, value: 'alice' }, 342 | { matched: true, value: 'fred' }, 343 | ], 344 | }, 345 | ], 346 | }, 347 | { matched: true, value: 'bob' }, 348 | ], 349 | }) 350 | }) 351 | }) 352 | 353 | describe('group example', () => { 354 | test('just group', () => { 355 | const matcher = matchArray([group('alice', 'fred'), 'bob']) 356 | 357 | expect(matcher(['alice', 'fred', 'bob'])).toEqual({ 358 | matched: true, 359 | value: [['alice', 'fred'], 'bob'], 360 | result: [ 361 | { 362 | matched: true, 363 | value: ['alice', 'fred'], 364 | result: [ 365 | { matched: true, value: 'alice' }, 366 | { matched: true, value: 'fred' }, 367 | ], 368 | }, 369 | { matched: true, value: 'bob' }, 370 | ], 371 | }) 372 | 373 | expectUnmatched(matcher(['alice', 'eve', 'bob'])) 374 | expectUnmatched(matcher(['eve', 'fred', 'bob'])) 375 | expectUnmatched(matcher(['alice', 'bob'])) 376 | expectUnmatched(matcher(['fred', 'bob'])) 377 | expectUnmatched(matcher(['bob'])) 378 | }) 379 | test('group of maybe', () => { 380 | const matcher = matchArray([group(maybe('alice'), 'fred'), 'bob']) 381 | 382 | expect(matcher(['fred', 'bob'])).toEqual({ 383 | matched: true, 384 | value: [[undefined, 'fred'], 'bob'], 385 | result: [ 386 | { 387 | matched: true, 388 | value: [undefined, 'fred'], 389 | result: [ 390 | { matched: true, value: undefined }, 391 | { matched: true, value: 'fred' }, 392 | ], 393 | }, 394 | { matched: true, value: 'bob' }, 395 | ], 396 | }) 397 | expect(matcher(['alice', 'fred', 'bob'])).toEqual({ 398 | matched: true, 399 | value: [['alice', 'fred'], 'bob'], 400 | result: [ 401 | { 402 | matched: true, 403 | value: ['alice', 'fred'], 404 | result: [ 405 | { matched: true, value: 'alice' }, 406 | { matched: true, value: 'fred' }, 407 | ], 408 | }, 409 | { matched: true, value: 'bob' }, 410 | ], 411 | }) 412 | }) 413 | test('group of some', () => { 414 | const matcher = matchArray([group(some('alice'), 'fred'), 'bob']) 415 | 416 | expect(matcher(['alice', 'fred', 'bob'])).toEqual({ 417 | matched: true, 418 | value: [[['alice'], 'fred'], 'bob'], 419 | result: [ 420 | { 421 | matched: true, 422 | value: [['alice'], 'fred'], 423 | result: [ 424 | { 425 | matched: true, 426 | value: ['alice'], 427 | result: [{ matched: true, value: 'alice' }], 428 | }, 429 | { matched: true, value: 'fred' }, 430 | ], 431 | }, 432 | { matched: true, value: 'bob' }, 433 | ], 434 | }) 435 | expect(matcher(['alice', 'alice', 'fred', 'bob'])).toEqual({ 436 | matched: true, 437 | value: [[['alice', 'alice'], 'fred'], 'bob'], 438 | result: [ 439 | { 440 | matched: true, 441 | value: [['alice', 'alice'], 'fred'], 442 | result: [ 443 | { 444 | matched: true, 445 | value: ['alice', 'alice'], 446 | result: [ 447 | { matched: true, value: 'alice' }, 448 | { matched: true, value: 'alice' }, 449 | ], 450 | }, 451 | { matched: true, value: 'fred' }, 452 | ], 453 | }, 454 | { matched: true, value: 'bob' }, 455 | ], 456 | }) 457 | 458 | expectUnmatched(matcher(['fred', 'bob'])) 459 | }) 460 | }) 461 | 462 | test('use asInternalIterator', () => { 463 | const matcher = group('a', 'b', 'c') 464 | 465 | expect(matcher(asInternalIterator('abc'))).toEqual({ 466 | matched: true, 467 | value: ['a', 'b', 'c'], 468 | result: [ 469 | { 470 | matched: true, 471 | value: 'a', 472 | }, 473 | { 474 | matched: true, 475 | value: 'b', 476 | }, 477 | { 478 | matched: true, 479 | value: 'c', 480 | }, 481 | ], 482 | }) 483 | }) 484 | }) 485 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/README.md: -------------------------------------------------------------------------------- 1 | # TC39 proposal pattern matching 2 | 3 | This folder contains TC39 proposal pattern matching [samples](./sample.js) but rewritten in `patcom`. Unit tests in this folder exercise the samples in more detail. 4 | 5 | Included with `patcom` is a variation of [`match`](./index.js) which implements caching iterators and object property accesses. 6 | 7 | ```js 8 | import {match} from 'patcom/tc39-proposal-pattern-matching' 9 | ``` 10 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/adventure-command.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { AdventureCommand } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('adventure command sample', () => { 7 | test.each([ 8 | { 9 | command: ['go', 'north'], 10 | expectedDir: 'north', 11 | }, 12 | { 13 | command: ['go', 'east'], 14 | expectedDir: 'east', 15 | }, 16 | { 17 | command: ['go', 'south'], 18 | expectedDir: 'south', 19 | }, 20 | { 21 | command: ['go', 'west'], 22 | expectedDir: 'west', 23 | }, 24 | ])('calls handleGoDir on $command', ({ command, expectedDir }) => { 25 | const handleGoDir = jest.fn() 26 | const handleTakeItem = jest.fn() 27 | const handleOtherwise = jest.fn() 28 | const matcher = AdventureCommand( 29 | handleGoDir, 30 | handleTakeItem, 31 | handleOtherwise 32 | ) 33 | matcher(command) 34 | expect(handleGoDir).toHaveBeenNthCalledWith(1, expectedDir) 35 | expect(handleTakeItem).not.toHaveBeenCalled() 36 | expect(handleOtherwise).not.toHaveBeenCalled() 37 | }) 38 | 39 | test('calls handleTakeItem on ["take", "something ball"], where "something ball" has a weight field', () => { 40 | const handleGoDir = jest.fn() 41 | const handleTakeItem = jest.fn() 42 | const handleOtherwise = jest.fn() 43 | const matcher = AdventureCommand( 44 | handleGoDir, 45 | handleTakeItem, 46 | handleOtherwise 47 | ) 48 | const ball = new String('something ball') 49 | ball.weight = 69 50 | matcher(['take', ball]) 51 | expect(handleTakeItem).toHaveBeenNthCalledWith(1, ball) 52 | expect(handleGoDir).not.toHaveBeenCalled() 53 | expect(handleOtherwise).not.toHaveBeenCalled() 54 | }) 55 | 56 | test('calls handleOtherwise unsupported command', () => { 57 | const handleGoDir = jest.fn() 58 | const handleTakeItem = jest.fn() 59 | const handleOtherwise = jest.fn() 60 | const matcher = AdventureCommand( 61 | handleGoDir, 62 | handleTakeItem, 63 | handleOtherwise 64 | ) 65 | matcher(['attack']) 66 | expect(handleOtherwise).toHaveBeenCalledTimes(1) 67 | expect(handleGoDir).not.toHaveBeenCalled() 68 | expect(handleTakeItem).not.toHaveBeenCalled() 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/array-length.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { ArrayLength } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('array length sample', () => { 7 | test('calls handleEmpty on empty res', () => { 8 | const handleEmpty = jest.fn() 9 | const handleSinglePage = jest.fn() 10 | const handleMultiplePages = jest.fn() 11 | const handleOtherwise = jest.fn() 12 | const matcher = ArrayLength( 13 | handleEmpty, 14 | handleSinglePage, 15 | handleMultiplePages, 16 | handleOtherwise 17 | ) 18 | const res = {} 19 | matcher(res) 20 | expect(handleEmpty).toHaveBeenNthCalledWith(1) 21 | expect(handleSinglePage).not.toHaveBeenCalled() 22 | expect(handleMultiplePages).not.toHaveBeenCalled() 23 | expect(handleOtherwise).not.toHaveBeenCalled() 24 | }) 25 | 26 | test('calls handleSinglePage on res with single page in data', () => { 27 | const handleEmpty = jest.fn() 28 | const handleSinglePage = jest.fn() 29 | const handleMultiplePages = jest.fn() 30 | const handleOtherwise = jest.fn() 31 | const matcher = ArrayLength( 32 | handleEmpty, 33 | handleSinglePage, 34 | handleMultiplePages, 35 | handleOtherwise 36 | ) 37 | const res = { data: ['some page'] } 38 | matcher(res) 39 | expect(handleSinglePage).toHaveBeenNthCalledWith(1, 'some page') 40 | expect(handleEmpty).not.toHaveBeenCalled() 41 | expect(handleMultiplePages).not.toHaveBeenCalled() 42 | expect(handleOtherwise).not.toHaveBeenCalled() 43 | }) 44 | 45 | test('calls handleMultiplePages on res with mutiple pages in data', () => { 46 | const handleEmpty = jest.fn() 47 | const handleSinglePage = jest.fn() 48 | const handleMultiplePages = jest.fn() 49 | const handleOtherwise = jest.fn() 50 | const matcher = ArrayLength( 51 | handleEmpty, 52 | handleSinglePage, 53 | handleMultiplePages, 54 | handleOtherwise 55 | ) 56 | const res = { data: ['some page', 'some other page', 'some more pages'] } 57 | matcher(res) 58 | expect(handleMultiplePages).toHaveBeenNthCalledWith(1, 'some page', [ 59 | 'some other page', 60 | 'some more pages', 61 | ]) 62 | expect(handleSinglePage).not.toHaveBeenCalled() 63 | expect(handleEmpty).not.toHaveBeenCalled() 64 | expect(handleOtherwise).not.toHaveBeenCalled() 65 | }) 66 | 67 | test('calls handleOtherwise when missing data is empty', () => { 68 | const handleEmpty = jest.fn() 69 | const handleSinglePage = jest.fn() 70 | const handleMultiplePages = jest.fn() 71 | const handleOtherwise = jest.fn() 72 | const matcher = ArrayLength( 73 | handleEmpty, 74 | handleSinglePage, 75 | handleMultiplePages, 76 | handleOtherwise 77 | ) 78 | const res = { data: [] } 79 | matcher(res) 80 | expect(handleOtherwise).toHaveBeenNthCalledWith(1) 81 | expect(handleSinglePage).not.toHaveBeenCalled() 82 | expect(handleMultiplePages).not.toHaveBeenCalled() 83 | expect(handleEmpty).not.toHaveBeenCalled() 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/array-pattern-caching.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { ArrayPatternCaching } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('array pattern caching sample', () => { 7 | test('logs found one when given one integer', () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | ArrayPatternCaching(1) 10 | expect(log).toHaveBeenNthCalledWith(1, 'found one int: 1') 11 | expect(log).toHaveBeenNthCalledWith(2, []) 12 | }) 13 | 14 | test('logs found two when given two integers', () => { 15 | const log = jest.spyOn(global.console, 'log').mockImplementation() 16 | ArrayPatternCaching(2) 17 | expect(log).toHaveBeenNthCalledWith(1, 'found two ints: 1 and 2') 18 | expect(log).toHaveBeenNthCalledWith(2, []) 19 | }) 20 | 21 | test('logs otherwise case and 4, 5 when given 5 integers', () => { 22 | const log = jest.spyOn(global.console, 'log').mockImplementation() 23 | ArrayPatternCaching(5) 24 | expect(log).toHaveBeenNthCalledWith(1, 'more than two ints') 25 | expect(log).toHaveBeenNthCalledWith(2, [4, 5]) 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/ascii-ci.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { AsciiCi } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('ascii ci sample', () => { 7 | test('call console.log with value if name is color', () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | const matcher = AsciiCi 10 | matcher({ 11 | name: 'color', 12 | value: 'red', 13 | }) 14 | expect(log).toHaveBeenNthCalledWith(1, 'color: red') 15 | }) 16 | 17 | test('call console.log with value if name is COLOR', () => { 18 | const log = jest.spyOn(global.console, 'log').mockImplementation() 19 | const matcher = AsciiCi 20 | matcher({ 21 | name: 'COLOR', 22 | value: 'red', 23 | }) 24 | expect(log).toHaveBeenNthCalledWith(1, 'color: red') 25 | }) 26 | 27 | test('does nothing if name is font', () => { 28 | const log = jest.spyOn(global.console, 'log').mockImplementation() 29 | const matcher = AsciiCi 30 | matcher({ 31 | name: 'font', 32 | value: 'fira code', 33 | }) 34 | expect(log).not.toHaveBeenCalled() 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/async-match.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { AsyncMatch } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('async match sample', () => { 7 | test('returns a promise for an object with the field a', async () => { 8 | const somethingThatRejects = jest.fn() 9 | const matcher = AsyncMatch( 10 | somethingThatRejects, 11 | Promise.resolve({ a: Promise.resolve('some value for a') }) 12 | ) 13 | await expect(matcher).resolves.toBe('some value for a') 14 | expect(somethingThatRejects).not.toHaveBeenCalled() 15 | }) 16 | 17 | test('returns a promise 42 for an object with the field b', async () => { 18 | const somethingThatRejects = jest.fn() 19 | const matcher = AsyncMatch( 20 | somethingThatRejects, 21 | Promise.resolve({ b: Promise.resolve('some value for b') }) 22 | ) 23 | await expect(matcher).resolves.toBe(42) 24 | expect(somethingThatRejects).not.toHaveBeenCalled() 25 | }) 26 | 27 | test('returns a rejected promise empty object', async () => { 28 | const somethingThatRejects = jest.fn().mockRejectedValue(69) 29 | const matcher = AsyncMatch(somethingThatRejects, Promise.resolve({})) 30 | await expect(matcher).rejects.toBe(69) 31 | expect(somethingThatRejects).toHaveBeenCalled() 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/built-in-custom-matchers.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { BuiltInCustomMatchers } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('built-in custom matchers sample', () => { 7 | test.each([ 8 | { 9 | type: 'Number', 10 | value: 42, 11 | }, 12 | { 13 | type: 'BigInt', 14 | value: BigInt(42), 15 | }, 16 | { 17 | type: 'String', 18 | value: '42', 19 | }, 20 | { 21 | type: 'Array', 22 | value: [42], 23 | }, 24 | { 25 | type: 'otherwise', 26 | value: false, 27 | }, 28 | ])('call console.log with $type $value', ({ type, value }) => { 29 | const log = jest.spyOn(global.console, 'log').mockImplementation() 30 | const matcher = BuiltInCustomMatchers 31 | matcher(value) 32 | expect(log).toHaveBeenNthCalledWith(1, type, value) 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/chaining-guards.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { ChainingGuards } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('chaining guards sample', () => { 7 | test('call console.log with multiple pages when pages is 2', async () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | ChainingGuards({ pages: 2, data: {} }) 10 | expect(log).toHaveBeenNthCalledWith(1, 'multiple pages') 11 | }) 12 | 13 | test('call console.log with one page when pages is 1', async () => { 14 | const log = jest.spyOn(global.console, 'log').mockImplementation() 15 | ChainingGuards({ pages: 1, data: {} }) 16 | expect(log).toHaveBeenNthCalledWith(1, 'one page') 17 | }) 18 | 19 | test('call console.log with no pages when pages is zero', async () => { 20 | const log = jest.spyOn(global.console, 'log').mockImplementation() 21 | ChainingGuards({ pages: 0, data: {} }) 22 | expect(log).toHaveBeenNthCalledWith(1, 'no pages') 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/comparison.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOMServer from 'react-dom/server.js' 3 | 4 | const Del = ({ children }) => ( 5 | 12 | {children} 13 | 14 | ) 15 | const Add = ({ children }) => ( 16 | 23 | {children} 24 | 25 | ) 26 | const patcom = ( 27 |
 38 |     
39 | match (res) {'{'} 40 |
41 |
{' '}when (
42 |
43 | {' '} 44 | {'{ status: 200, body, '} 45 | ... 46 | {'rest }'} 47 |
48 |
49 | {' '} 50 | ): 51 |
52 |
53 | {' '} 54 | handleData(body, rest) 55 |
56 |
57 |
{' '}when (
58 |
59 | {' '} 60 | {'{ status, destination: '} 61 | url 62 | {' }'} 63 |
64 |
65 | {' '} 66 | {') if (300 <= status && status < 400):'} 67 |
68 |
{' '}handleRedirect(url)
69 |
70 |
{' '}when (
71 |
72 | {' '} 73 | {'{ status: 500 }'} 74 |
75 |
76 | {' '} 77 | ) if (!this.hasRetried): 78 |
79 |
80 | {' '} 81 | do 82 | {'{'} 83 |
84 |
{' '}retry(req)
85 |
{' '}this.hasRetried = true
86 |
87 | {' '} 88 | {'}'} 89 |
90 |
91 | 92 |
93 | {' '} 94 | default: throwSomething() 95 |
96 |
97 | {'}'} 98 |
99 | 100 |
101 | match (res) ( 102 |
103 |
104 | {' '} 105 | when ( 106 |
107 |
108 | {' '} 109 | {'{ status: 200, body'} 110 | : defined 111 | {', rest }'} 112 | , 113 |
114 |
115 | {' '} 116 | ({'{ body, rest }) =>'} 117 |
118 |
119 | {' '} 120 | handleData(body, rest) 121 |
122 |
123 | {' '} 124 | ), 125 |
126 |
127 | {' '} 128 | when ( 129 |
130 |
131 | {' '} 132 | {'{ status'} 133 | : between(300, 400), destination: defined 134 | {' }'} 135 | , 136 |
137 |
138 | {' '} 139 | {'({ destination: url }) =>'} 140 |
141 |
142 | {' '} 143 | handleRedirect(url) 144 |
145 |
146 | {' '} 147 | ), 148 |
149 |
150 | {' '} 151 | when ( 152 |
153 |
154 | {' '} 155 | {'{ status: 500 }'} 156 | , 157 |
158 |
159 | {' '} 160 | {'() =>'} !this.hasRetried, 161 |
162 |
163 | {' '} 164 | {'() =>'} 165 | {'{'} 166 |
167 |
168 | {' '} 169 | retry(req) 170 |
171 |
172 | {' '} 173 | this.hasRetried = true 174 |
175 |
176 | {' '} 177 | {'}'} 178 |
179 |
180 | {' '} 181 | ), 182 |
183 |
184 | {' '} 185 | {'otherwise (() =>'} throwSomething()) 186 |
187 |
188 | ) 189 |
190 |
191 | ) 192 | 193 | console.log(ReactDOMServer.renderToStaticMarkup(patcom)) 194 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/conditional-jsx.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { ConditionalJsx } from './sample.js' 4 | 5 | function h(component, props, children) { 6 | return component({ ...props, children }) 7 | } 8 | describe('tc39-proposal-pattern-matching', () => { 9 | describe('conditional jsx sample', () => { 10 | test('returns Loading component when loading field is present', () => { 11 | const Fetch = ({ children }) => { 12 | return children({ loading: true }) 13 | } 14 | const Loading = () => 'Loading' 15 | const Error = ({ error }) => `Failed with ${error}` 16 | const Page = ({ data }) => `Page with ${data}` 17 | const Component = ConditionalJsx( 18 | h, 19 | 'some api url', 20 | Fetch, 21 | Loading, 22 | Error, 23 | Page 24 | ) 25 | expect(h(Component)).toBe('Loading') 26 | }) 27 | 28 | test('returns Error component when error field is present', () => { 29 | const Fetch = ({ children }) => { 30 | return children({ error: 'some error' }) 31 | } 32 | global.console.err = jest.fn() 33 | const Loading = () => 'Loading' 34 | const Error = ({ error }) => `Failed with ${error}` 35 | const Page = ({ data }) => `Page with ${data}` 36 | const Component = ConditionalJsx( 37 | h, 38 | 'some api url', 39 | Fetch, 40 | Loading, 41 | Error, 42 | Page 43 | ) 44 | expect(h(Component)).toBe('Failed with some error') 45 | expect(global.console.err).toHaveBeenNthCalledWith( 46 | 1, 47 | 'something bad happened' 48 | ) 49 | delete global.console.err 50 | }) 51 | 52 | test('returns Page component when data field is present', () => { 53 | const Fetch = ({ children }) => { 54 | return children({ data: 'some data' }) 55 | } 56 | const Loading = () => 'Loading' 57 | const Error = ({ error }) => `Failed with ${error}` 58 | const Page = ({ data }) => `Page with ${data}` 59 | const Component = ConditionalJsx( 60 | h, 61 | 'some api url', 62 | Fetch, 63 | Loading, 64 | Error, 65 | Page 66 | ) 67 | expect(h(Component)).toBe('Page with some data') 68 | }) 69 | 70 | test('returns nothing otherwise', () => { 71 | const Fetch = ({ children }) => { 72 | return children({}) 73 | } 74 | const Loading = () => 'Loading' 75 | const Error = ({ error }) => `Failed with ${error}` 76 | const Page = ({ data }) => `Page with ${data}` 77 | const Component = ConditionalJsx( 78 | h, 79 | 'some api url', 80 | Fetch, 81 | Loading, 82 | Error, 83 | Page 84 | ) 85 | expect(h(Component)).toBe() 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/custom-matcher-option.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { CustomMatcherOption, Option } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('customer matcher sample', () => { 7 | test('call console.log with val on some result', () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | const matcher = CustomMatcherOption 10 | matcher(Option.Some('some value')) 11 | expect(log).toHaveBeenNthCalledWith(1, 'some value') 12 | }) 13 | 14 | test('call console.log with none on none result', () => { 15 | const log = jest.spyOn(global.console, 'log').mockImplementation() 16 | const matcher = CustomMatcherOption 17 | matcher(Option.None()) 18 | expect(log).toHaveBeenNthCalledWith(1, 'none') 19 | }) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/diff.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/concept-not-found/patcom/b0ce82e93e95d02123e7aff2bb1b7a1c4d8fd488/tc39-proposal-pattern-matching/diff.png -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/fetch-json-response.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { FetchJsonResponse } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('fetch json response sample', () => { 7 | test('call console.log with content length on 200', async () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | async function fetch() { 10 | return { 11 | status: 200, 12 | headers: { 'Content-Length': 42 }, 13 | } 14 | } 15 | await FetchJsonResponse(fetch) 16 | expect(log).toHaveBeenNthCalledWith(1, 'size is 42') 17 | }) 18 | 19 | test('call console.log with not found on 404', async () => { 20 | const log = jest.spyOn(global.console, 'log').mockImplementation() 21 | async function fetch() { 22 | return { 23 | status: 404, 24 | } 25 | } 26 | await FetchJsonResponse(fetch) 27 | expect(log).toHaveBeenNthCalledWith(1, 'JSON not found') 28 | }) 29 | 30 | test.each([{ status: 400 }, { status: 401 }, { status: 500 }])( 31 | 'throws if status is $status', 32 | ({ status }) => { 33 | async function fetch() { 34 | return { 35 | status, 36 | } 37 | } 38 | expect(FetchJsonResponse(fetch)).rejects.toThrow() 39 | } 40 | ) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/index.js: -------------------------------------------------------------------------------- 1 | import { ValueMatcher, asMatcher, unmatched } from '../index.js' 2 | import TimeJumpIterator from '../time-jump-iterator.js' 3 | 4 | export function cachedProperties(source) { 5 | const cache = {} 6 | return new Proxy(source, { 7 | get(target, prop) { 8 | if (!cache[prop]) { 9 | cache[prop] = source[prop] 10 | } 11 | return cache[prop] 12 | }, 13 | }) 14 | } 15 | 16 | export const cachingOneOf = (...matchables) => 17 | ValueMatcher((value) => { 18 | const iteratorValue = 19 | typeof value !== 'string' && 20 | !Array.isArray(value) && 21 | value[Symbol.iterator] 22 | if (iteratorValue) { 23 | value = TimeJumpIterator(value) 24 | } else if (typeof value === 'object') { 25 | value = cachedProperties(value) 26 | } 27 | for (const matchable of matchables) { 28 | if (iteratorValue) { 29 | value.jump(0) 30 | } 31 | const matcher = asMatcher(matchable) 32 | const result = matcher(value) 33 | if (result.matched) { 34 | return result 35 | } 36 | } 37 | return unmatched 38 | }) 39 | 40 | export const match = 41 | (value) => 42 | (...clauses) => { 43 | const result = cachingOneOf(...clauses)(value) 44 | return result.value 45 | } 46 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/nil-pattern.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { NilPattern } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('nil pattern sample', () => { 7 | test('call console.log 3rd value with two holes before it', async () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | NilPattern([, , 'some value']) 10 | expect(log).toHaveBeenNthCalledWith(1, 'some value') 11 | }) 12 | 13 | test('call console.log 3rd value with filled holes', async () => { 14 | const log = jest.spyOn(global.console, 'log').mockImplementation() 15 | NilPattern(['filled', 'filled', 'some value']) 16 | expect(log).toHaveBeenNthCalledWith(1, 'some value') 17 | }) 18 | 19 | test('does nothing with 3 holes', async () => { 20 | const log = jest.spyOn(global.console, 'log').mockImplementation() 21 | NilPattern([, ,]) 22 | expect(log).not.toHaveBeenCalled() 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/object-pattern-caching.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { ObjectPatternCaching } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('object pattern caching sample', () => { 7 | test('matches number with single read to Math.random less than half', () => { 8 | const log = jest.spyOn(global.console, 'log').mockReturnValue() 9 | const random = jest.spyOn(global.Math, 'random').mockReturnValue(0.2) 10 | ObjectPatternCaching() 11 | expect(log).toHaveBeenNthCalledWith(1, 'Only matches half the time.') 12 | expect(random).toHaveBeenCalledTimes(1) 13 | }) 14 | 15 | test('matches string with single read to Math.random greater than half', () => { 16 | const log = jest.spyOn(global.console, 'log').mockReturnValue() 17 | const random = jest.spyOn(global.Math, 'random').mockReturnValue(0.8) 18 | ObjectPatternCaching() 19 | expect(log).toHaveBeenNthCalledWith( 20 | 1, 21 | 'Guaranteed to match the other half of the time.' 22 | ) 23 | expect(random).toHaveBeenCalledTimes(1) 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/regexp-groups.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { RegExpGroups } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('regexp groups sample', () => { 7 | test('calls process with named groups on 1 + 2', () => { 8 | const process = jest.fn() 9 | const handleOtherwise = jest.fn() 10 | const matcher = RegExpGroups(process, handleOtherwise) 11 | const arithmeticStr = '1 + 2' 12 | matcher(arithmeticStr) 13 | expect(process).toHaveBeenNthCalledWith(1, '1', '2') 14 | expect(handleOtherwise).not.toHaveBeenCalled() 15 | }) 16 | 17 | test('calls process with array match on 1 * 2', () => { 18 | const process = jest.fn() 19 | const handleOtherwise = jest.fn() 20 | const matcher = RegExpGroups(process, handleOtherwise) 21 | const arithmeticStr = '1 * 2' 22 | matcher(arithmeticStr) 23 | expect(process).toHaveBeenNthCalledWith(1, '1', '2') 24 | expect(handleOtherwise).not.toHaveBeenCalled() 25 | }) 26 | 27 | test('calls handleOtherwise on 1 / 2', () => { 28 | const process = jest.fn() 29 | const handleOtherwise = jest.fn() 30 | const matcher = RegExpGroups(process, handleOtherwise) 31 | const arithmeticStr = '1 / 2' 32 | matcher(arithmeticStr) 33 | expect(handleOtherwise).toHaveBeenNthCalledWith(1) 34 | expect(process).not.toHaveBeenCalled() 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/res-handler.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { ResHandler } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('res handler sample', () => { 7 | test('handleData called on status 200', () => { 8 | const handleData = jest.fn() 9 | const handleRedirect = jest.fn() 10 | const retry = jest.fn() 11 | const throwSomething = jest.fn() 12 | const RetryableHandler = ResHandler( 13 | handleData, 14 | handleRedirect, 15 | retry, 16 | throwSomething 17 | ) 18 | const retryableHandler = new RetryableHandler() 19 | const req = 'some request' 20 | const res = { 21 | status: 200, 22 | body: 'some body', 23 | otherField: 'other field', 24 | moreField: 'more field', 25 | } 26 | retryableHandler.handle(req, res) 27 | expect(handleData).toHaveBeenNthCalledWith(1, 'some body', { 28 | otherField: 'other field', 29 | moreField: 'more field', 30 | }) 31 | expect(handleRedirect).not.toHaveBeenCalled() 32 | expect(retry).not.toHaveBeenCalled() 33 | expect(throwSomething).not.toHaveBeenCalled() 34 | }) 35 | 36 | test.each([ 37 | { 38 | status: 300, 39 | }, 40 | { 41 | status: 301, 42 | }, 43 | { 44 | status: 302, 45 | }, 46 | { 47 | status: 399, 48 | }, 49 | ])('handleRedirect called on status $status', ({ status }) => { 50 | const handleData = jest.fn() 51 | const handleRedirect = jest.fn() 52 | const retry = jest.fn() 53 | const throwSomething = jest.fn() 54 | const RetryableHandler = ResHandler( 55 | handleData, 56 | handleRedirect, 57 | retry, 58 | throwSomething 59 | ) 60 | const retryableHandler = new RetryableHandler() 61 | const req = 'some request' 62 | const res = { 63 | status, 64 | destination: 'some url', 65 | } 66 | retryableHandler.handle(req, res) 67 | expect(handleRedirect).toHaveBeenNthCalledWith(1, 'some url') 68 | expect(handleData).not.toHaveBeenCalled() 69 | expect(retry).not.toHaveBeenCalled() 70 | expect(throwSomething).not.toHaveBeenCalled() 71 | }) 72 | 73 | test('retry called on status 500 on the first attempt', () => { 74 | const handleData = jest.fn() 75 | const handleRedirect = jest.fn() 76 | const retry = jest.fn() 77 | const throwSomething = jest.fn() 78 | const RetryableHandler = ResHandler( 79 | handleData, 80 | handleRedirect, 81 | retry, 82 | throwSomething 83 | ) 84 | const retryableHandler = new RetryableHandler() 85 | const req = 'some request' 86 | const res = { 87 | status: 500, 88 | } 89 | retryableHandler.handle(req, res) 90 | expect(retry).toHaveBeenNthCalledWith(1, 'some request') 91 | expect(handleData).not.toHaveBeenCalled() 92 | expect(handleRedirect).not.toHaveBeenCalled() 93 | expect(throwSomething).not.toHaveBeenCalled() 94 | }) 95 | 96 | test('throwSomething called on consecutive status 500', () => { 97 | const handleData = jest.fn() 98 | const handleRedirect = jest.fn() 99 | const retry = jest.fn() 100 | const throwSomething = jest.fn() 101 | const RetryableHandler = ResHandler( 102 | handleData, 103 | handleRedirect, 104 | retry, 105 | throwSomething 106 | ) 107 | const retryableHandler = new RetryableHandler() 108 | const req = 'some request' 109 | const res = { 110 | status: 500, 111 | } 112 | retryableHandler.handle(req, res) 113 | retryableHandler.handle(req, res) 114 | expect(retry).toHaveBeenCalledTimes(1) 115 | expect(throwSomething).toHaveBeenCalledTimes(1) 116 | expect(handleData).not.toHaveBeenCalled() 117 | expect(handleRedirect).not.toHaveBeenCalled() 118 | }) 119 | 120 | test('throwSomething called on status 400', () => { 121 | const handleData = jest.fn() 122 | const handleRedirect = jest.fn() 123 | const retry = jest.fn() 124 | const throwSomething = jest.fn() 125 | const RetryableHandler = ResHandler( 126 | handleData, 127 | handleRedirect, 128 | retry, 129 | throwSomething 130 | ) 131 | const retryableHandler = new RetryableHandler() 132 | const req = 'some request' 133 | const res = { 134 | status: 400, 135 | } 136 | retryableHandler.handle(req, res) 137 | expect(throwSomething).toHaveBeenCalledTimes(1) 138 | expect(handleData).not.toHaveBeenCalled() 139 | expect(retry).not.toHaveBeenCalled() 140 | expect(handleRedirect).not.toHaveBeenCalled() 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/sample.js: -------------------------------------------------------------------------------- 1 | import { 2 | matcher, 3 | ValueMatcher, 4 | matchNumber, 5 | matchBigInt, 6 | matchString, 7 | matchArray, 8 | greaterThan, 9 | greaterThanEquals, 10 | defined, 11 | rest, 12 | oneOf, 13 | allOf, 14 | matchProp, 15 | when, 16 | otherwise, 17 | between, 18 | any, 19 | } from '../index.js' 20 | 21 | import { match } from './index.js' 22 | 23 | export const AdventureCommand = 24 | (handleGoDir, handleTakeItem, handleOtherwise) => (command) => 25 | match(command)( 26 | when(['go', oneOf('north', 'east', 'south', 'west')], ([, dir]) => 27 | handleGoDir(dir) 28 | ), 29 | when(['take', allOf(/[a-z]+ ball/, matchProp('weight'))], ([, item]) => 30 | handleTakeItem(item) 31 | ), 32 | otherwise(() => handleOtherwise()) 33 | ) 34 | 35 | export const ArrayLength = 36 | (handleEmpty, handleSinglePage, handleMultiplePages, handleOtherwise) => 37 | (res) => 38 | match(res)( 39 | when({}, () => handleEmpty()), 40 | when({ data: [defined] }, ({ data: [page] }) => handleSinglePage(page)), 41 | when({ data: [defined, rest] }, ({ data: [frontPage, pages] }) => 42 | handleMultiplePages(frontPage, pages) 43 | ), 44 | otherwise(() => handleOtherwise()) 45 | ) 46 | 47 | function* integers(to) { 48 | for (var i = 1; i <= to; i++) yield i 49 | } 50 | 51 | export const ArrayPatternCaching = (n) => { 52 | const iterable = integers(n) 53 | match(iterable)( 54 | when([matchNumber()], ([a]) => console.log(`found one int: ${a}`)), 55 | // Matching a generator against an array pattern. 56 | // Obtain the iterator (which is just the generator itself), 57 | // then pull two items: 58 | // one to match against the `a` pattern (which succeeds), 59 | // the second to verify the iterator only has one item 60 | // (which fails). 61 | when([matchNumber(), matchNumber()], ([a, b]) => 62 | console.log(`found two ints: ${a} and ${b}`) 63 | ), 64 | // Matching against an array pattern again. 65 | // The generator object has already been cached, 66 | // so we fetch the cached results. 67 | // We need three items in total; 68 | // two to check against the patterns, 69 | // and the third to verify the iterator has only two items. 70 | // Two are already in the cache, 71 | // so we’ll just pull one more (and fail the pattern). 72 | otherwise(() => console.log('more than two ints')) 73 | ) 74 | console.log([...iterable]) 75 | // logs [4, 5] 76 | // The match construct pulled three elements from the generator, 77 | // so there’s two leftover afterwards. 78 | } 79 | 80 | function asciiCI(str) { 81 | return { 82 | [matcher](matchable) { 83 | return { 84 | matched: str.toLowerCase() == matchable.toLowerCase(), 85 | } 86 | }, 87 | } 88 | } 89 | 90 | export const AsciiCi = (cssProperty) => 91 | match(cssProperty)( 92 | when({ name: asciiCI('color'), value: defined }, ({ value }) => 93 | console.log('color: ' + value) 94 | ) 95 | // matches if `name` is an ASCII case-insensitive match 96 | // for "color", so `{name:"COLOR", value:"red"} would match. 97 | ) 98 | 99 | export const AsyncMatch = async (somethingThatRejects, matchable) => 100 | match(await matchable)( 101 | when({ a: defined }, async ({ a }) => await a), 102 | when({ b: defined }, ({ b }) => b.then(() => 42)), 103 | otherwise(async () => await somethingThatRejects()) 104 | ) // produces a Promise 105 | 106 | export const BuiltInCustomMatchers = (value) => 107 | match(value)( 108 | when(matchNumber(), (value) => console.log('Number', value)), 109 | when(matchBigInt(), (value) => console.log('BigInt', value)), 110 | when(matchString(), (value) => console.log('String', value)), 111 | when(matchArray(), (value) => console.log('Array', value)), 112 | otherwise((value) => console.log('otherwise', value)) 113 | ) 114 | 115 | export const ChainingGuards = (res) => 116 | match(res)( 117 | when({ pages: greaterThan(1), data: defined }, () => 118 | console.log('multiple pages') 119 | ), 120 | when({ pages: 1, data: defined }, () => console.log('one page')), 121 | otherwise(() => console.log('no pages')) 122 | ) 123 | 124 | export const ConditionalJsx = (h, API_URL, Fetch, Loading, Error, Page) => () => 125 | h(Fetch, { url: API_URL }, (props) => 126 | match(props)( 127 | when({ loading: defined }, () => h(Loading)), 128 | when({ error: defined }, ({ error }) => { 129 | console.err('something bad happened') 130 | return h(Error, { error }) 131 | }), 132 | when({ data: defined }, ({ data }) => h(Page, { data })) 133 | ) 134 | ) 135 | 136 | export class Foo { 137 | static [matcher](value) { 138 | return { 139 | matched: value instanceof Foo, 140 | value, 141 | } 142 | } 143 | } 144 | 145 | const Exception = Error 146 | 147 | export class Option { 148 | constructor(hasValue, value) { 149 | this.hasValue = !!hasValue 150 | if (hasValue) { 151 | this._value = value 152 | } 153 | } 154 | get value() { 155 | if (this.hasValue) return this._value 156 | throw new Exception("Can't get the value of an Option.None.") 157 | } 158 | 159 | static Some(val) { 160 | return new Option(true, val) 161 | } 162 | static None() { 163 | return new Option(false) 164 | } 165 | } 166 | 167 | Option.Some[matcher] = ValueMatcher((val) => ({ 168 | matched: val instanceof Option && val.hasValue, 169 | value: val instanceof Option && val.hasValue && val.value, 170 | })) 171 | Option.None[matcher] = ValueMatcher((val) => ({ 172 | matched: val instanceof Option && !val.hasValue, 173 | })) 174 | 175 | export const CustomMatcherOption = (result) => 176 | match(result)( 177 | when(Option.Some, (val) => console.log(val)), 178 | when(Option.None, () => console.log('none')) 179 | ) 180 | 181 | const RequestError = Error 182 | 183 | export const FetchJsonResponse = async (fetch, jsonService) => { 184 | const res = await fetch(jsonService) 185 | match(res)( 186 | when( 187 | { status: 200, headers: { 'Content-Length': defined } }, 188 | ({ headers: { 'Content-Length': s } }) => console.log(`size is ${s}`) 189 | ), 190 | when({ status: 404 }, () => console.log('JSON not found')), 191 | when({ status: greaterThanEquals(400) }, () => { 192 | throw new RequestError(res) 193 | }) 194 | ) 195 | } 196 | 197 | export const NilPattern = (someArr) => 198 | match(someArr)( 199 | when([any, any, defined], ([, , someVal]) => console.log(someVal)) 200 | ) 201 | 202 | export const ObjectPatternCaching = () => { 203 | const randomItem = { 204 | get numOrString() { 205 | return Math.random() < 0.5 ? 1 : '1' 206 | }, 207 | } 208 | 209 | match(randomItem)( 210 | when({ numOrString: matchNumber() }, () => 211 | console.log('Only matches half the time.') 212 | ), 213 | // Whether the pattern matches or not, 214 | // we cache the (randomItem, "numOrString") pair 215 | // with the result. 216 | when({ numOrString: matchString() }, () => 217 | console.log('Guaranteed to match the other half of the time.') 218 | ) 219 | // Since (randomItem, "numOrString") has already been cached, 220 | // we reuse the result here; 221 | // if it was a string for the first clause, 222 | // it’s the same string here. 223 | ) 224 | } 225 | 226 | export const RegExpGroups = (process, handleOtherwise) => (arithmeticStr) => 227 | match(arithmeticStr)( 228 | when( 229 | /(?\d+) \+ (?\d+)/, 230 | (value, { matchedRegExp: { groups: { left, right } = {} } }) => 231 | process(left, right) 232 | ), 233 | when(/(\d+) \* (\d+)/, (value, { matchedRegExp: [, left, right] }) => 234 | process(left, right) 235 | ), 236 | otherwise(() => handleOtherwise()) 237 | ) 238 | 239 | export const ResHandler = (handleData, handleRedirect, retry, throwSomething) => 240 | class RetryableHandler { 241 | constructor() { 242 | this.hasRetried = false 243 | } 244 | 245 | handle(req, res) { 246 | match(res)( 247 | when({ status: 200, body: defined, rest }, ({ body, rest }) => 248 | handleData(body, rest) 249 | ), 250 | when( 251 | { status: between(300, 400), destination: matchString() }, 252 | ({ destination: url }) => handleRedirect(url) 253 | ), 254 | when( 255 | { status: 500 }, 256 | () => !this.hasRetried, 257 | () => { 258 | retry(req) 259 | this.hasRetried = true 260 | } 261 | ), 262 | otherwise(() => throwSomething()) 263 | ) 264 | } 265 | } 266 | 267 | export const TodoReducer = (initialState = {}) => 268 | function todosReducer(state = initialState, action) { 269 | return match(action)( 270 | when( 271 | { type: 'set-visibility-filter', payload: defined }, 272 | ({ payload: visFilter }) => ({ ...state, visFilter }) 273 | ), 274 | when({ type: 'add-todo', payload: defined }, ({ payload: text }) => ({ 275 | ...state, 276 | todos: [...state.todos, { text, completed: false }], 277 | })), 278 | when({ type: 'toggle-todo', payload: defined }, ({ payload: index }) => { 279 | const newTodos = state.todos.map((todo, i) => { 280 | return i !== index 281 | ? todo 282 | : { 283 | ...todo, 284 | completed: !todo.completed, 285 | } 286 | }) 287 | 288 | return { 289 | ...state, 290 | todos: newTodos, 291 | } 292 | }), 293 | otherwise(() => state) // ignore unknown actions 294 | ) 295 | } 296 | 297 | class MyClass { 298 | static [matcher](matchable) { 299 | return { 300 | matched: matchable === 3, 301 | value: { a: 1, b: { c: 2 } }, 302 | } 303 | } 304 | } 305 | 306 | export const WithChainingMyClass = () => 307 | match(3)( 308 | when(MyClass, () => true), // matches, doesn’t use the result 309 | when(MyClass, ({ a, b: { c } }) => { 310 | // passes the custom matcher, 311 | // then further applies an object pattern to the result’s value 312 | assert(a === 1) 313 | assert(c === 2) 314 | }) 315 | ) 316 | 317 | export const WithChainingRegExp = () => 318 | match('foobar')( 319 | when(/foo(.*)/, (value, { matchedRegExp: [, suffix] }) => 320 | console.log(suffix) 321 | ) 322 | // logs "bar", since the match result 323 | // is an array-like containing the whole match 324 | // followed by the groups. 325 | // note the hole at the start of the array matcher 326 | // ignoring the first item, 327 | // which is the entire match "foobar". 328 | ) 329 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/todo-reducer.spec.js: -------------------------------------------------------------------------------- 1 | import { TodoReducer } from './sample.js' 2 | 3 | describe('tc39-proposal-pattern-matching', () => { 4 | describe('todo-reducer sample', () => { 5 | test('add visFilter on set-visibility-filter action', () => { 6 | const reducer = TodoReducer() 7 | const state = reducer( 8 | { 9 | existing: 'state', 10 | }, 11 | { 12 | type: 'set-visibility-filter', 13 | payload: 'some visibility filter', 14 | } 15 | ) 16 | expect(state).toEqual({ 17 | existing: 'state', 18 | visFilter: 'some visibility filter', 19 | }) 20 | }) 21 | 22 | test('add todos on add-todo action', () => { 23 | const reducer = TodoReducer() 24 | const state = reducer( 25 | { 26 | existing: 'state', 27 | todos: [ 28 | { 29 | text: 'existing todo', 30 | completed: true, 31 | }, 32 | ], 33 | }, 34 | { 35 | type: 'add-todo', 36 | payload: 'new todo', 37 | } 38 | ) 39 | expect(state).toEqual({ 40 | existing: 'state', 41 | todos: [ 42 | { 43 | text: 'existing todo', 44 | completed: true, 45 | }, 46 | { 47 | text: 'new todo', 48 | completed: false, 49 | }, 50 | ], 51 | }) 52 | }) 53 | 54 | test('set a completed todo to be incomplete on toggle-todo action', () => { 55 | const reducer = TodoReducer() 56 | const state = reducer( 57 | { 58 | existing: 'state', 59 | todos: [ 60 | { 61 | text: 'existing todo', 62 | completed: true, 63 | }, 64 | ], 65 | }, 66 | { 67 | type: 'toggle-todo', 68 | payload: 0, 69 | } 70 | ) 71 | expect(state).toEqual({ 72 | existing: 'state', 73 | todos: [ 74 | { 75 | text: 'existing todo', 76 | completed: false, 77 | }, 78 | ], 79 | }) 80 | }) 81 | }) 82 | 83 | test('set an incomplete todo to be completed on toggle-todo action', () => { 84 | const reducer = TodoReducer() 85 | const state = reducer( 86 | { 87 | existing: 'state', 88 | todos: [ 89 | { 90 | text: 'existing todo', 91 | completed: false, 92 | }, 93 | ], 94 | }, 95 | { 96 | type: 'toggle-todo', 97 | payload: 0, 98 | } 99 | ) 100 | expect(state).toEqual({ 101 | existing: 'state', 102 | todos: [ 103 | { 104 | text: 'existing todo', 105 | completed: true, 106 | }, 107 | ], 108 | }) 109 | }) 110 | 111 | test('pass-through state on unknown action', () => { 112 | const reducer = TodoReducer() 113 | const state = reducer( 114 | { 115 | existing: 'state', 116 | }, 117 | { 118 | type: 'add-secret-todo', 119 | payload: 'shhh', 120 | } 121 | ) 122 | expect(state).toEqual({ 123 | existing: 'state', 124 | }) 125 | }) 126 | }) 127 | -------------------------------------------------------------------------------- /tc39-proposal-pattern-matching/with-chaining-regexp.spec.js: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals' 2 | 3 | import { WithChainingRegExp } from './sample.js' 4 | 5 | describe('tc39-proposal-pattern-matching', () => { 6 | describe('with chaining regexp sample', () => { 7 | test('call console.log with suffix after foo', () => { 8 | const log = jest.spyOn(global.console, 'log').mockImplementation() 9 | WithChainingRegExp() 10 | expect(log).toHaveBeenNthCalledWith(1, 'bar') 11 | }) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /time-jump-iterator.js: -------------------------------------------------------------------------------- 1 | export default (source) => { 2 | const previous = [] 3 | let sourceIndex = 0 4 | let sourceDone = false 5 | let index = 0 6 | return { 7 | get now() { 8 | return index 9 | }, 10 | jump(time = 0) { 11 | index = time 12 | }, 13 | next() { 14 | if (index === sourceIndex) { 15 | if (sourceDone) { 16 | return { 17 | value: undefined, 18 | done: true, 19 | } 20 | } 21 | const { value, done } = source.next() 22 | if (done) { 23 | sourceDone = true 24 | return { 25 | value: undefined, 26 | done: true, 27 | } 28 | } 29 | previous.push(value) 30 | sourceIndex += 1 31 | } 32 | return { 33 | value: previous[index++], 34 | done: false, 35 | } 36 | }, 37 | [Symbol.iterator]() { 38 | return this 39 | }, 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /time-jump-iterator.spec.js: -------------------------------------------------------------------------------- 1 | import TimeJumpIterator from './time-jump-iterator.js' 2 | 3 | describe('time jump iterator', () => { 4 | test('time jump itself is iterable', () => { 5 | function* numbers() { 6 | yield 1 7 | yield 2 8 | yield 3 9 | } 10 | 11 | const iterator = TimeJumpIterator(numbers()) 12 | expect(iterator[Symbol.iterator]).toBeDefined() 13 | }) 14 | 15 | test('passthrough source', () => { 16 | function* numbers() { 17 | yield 1 18 | yield 2 19 | yield 3 20 | } 21 | 22 | const iterator = TimeJumpIterator(numbers()) 23 | expect([...iterator]).toEqual([1, 2, 3]) 24 | }) 25 | 26 | test('can read for a second time after jump back to beginning', () => { 27 | function* numbers() { 28 | yield 1 29 | yield 2 30 | yield 3 31 | } 32 | 33 | const iterator = TimeJumpIterator(numbers()) 34 | expect([...iterator]).toEqual([1, 2, 3]) 35 | iterator.jump() 36 | expect([...iterator]).toEqual([1, 2, 3]) 37 | }) 38 | 39 | test('can tell how much time has passed', () => { 40 | function* numbers() { 41 | yield 1 42 | yield 2 43 | yield 3 44 | } 45 | 46 | const iterator = TimeJumpIterator(numbers()) 47 | const start = iterator.now 48 | iterator.next() 49 | iterator.next() 50 | const end = iterator.now 51 | expect(end - start).toEqual(2) 52 | }) 53 | 54 | test('jumps to time', () => { 55 | function* numbers() { 56 | yield 1 57 | yield 2 58 | yield 3 59 | } 60 | 61 | const iterator = TimeJumpIterator(numbers()) 62 | iterator.next() 63 | const time = iterator.now 64 | expect([...iterator]).toEqual([2, 3]) 65 | iterator.jump(time) 66 | expect([...iterator]).toEqual([2, 3]) 67 | }) 68 | 69 | test('can read fully after partial read', () => { 70 | function* numbers() { 71 | yield 1 72 | yield 2 73 | yield 3 74 | } 75 | 76 | const iterator = TimeJumpIterator(numbers()) 77 | iterator.next() 78 | iterator.jump() 79 | expect([...iterator]).toEqual([1, 2, 3]) 80 | }) 81 | 82 | test('consumes source lazily', () => { 83 | function* numbers() { 84 | yield 1 85 | yield 2 86 | yield 3 87 | } 88 | 89 | const iterable = numbers() 90 | const iterator = TimeJumpIterator(iterable) 91 | iterator.next() 92 | expect([...iterable]).toEqual([2, 3]) 93 | }) 94 | }) 95 | --------------------------------------------------------------------------------