├── .all-contributorsrc ├── .github ├── .kodiak.toml ├── renovate.json └── workflows │ └── push.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── benchmark.sh ├── dvx.json ├── esbuild.js ├── eslint.config.cjs ├── jest.config.js ├── package.json ├── src ├── average │ ├── average.spec.ts │ └── average.ts ├── benchmarkHelper.ts ├── chunk │ ├── chunk.spec.ts │ └── chunk.ts ├── clamp │ ├── clamp.spec.ts │ └── clamp.ts ├── clone │ ├── clone.spec.ts │ └── clone.ts ├── compact │ ├── compact.spec.ts │ └── compact.ts ├── debounce │ ├── debounce.spec.ts │ └── debounce.ts ├── duplicates │ ├── duplicates.spec.ts │ └── duplicates.ts ├── escapeRegExp │ ├── escapeRegExp.spec.ts │ └── escapeRegExp.ts ├── flatten │ ├── flatten.spec.ts │ └── flatten.ts ├── generateDocs.ts ├── get │ ├── BENCHMARK.md │ ├── get.benchmark.ts │ ├── get.spec.ts │ └── get.ts ├── hash │ ├── BENCHMARK.md │ ├── hash.benchmark.ts │ ├── hash.spec.ts │ └── hash.ts ├── identifier │ ├── identifier.spec.ts │ └── identifier.ts ├── index.spec.ts ├── index.ts ├── matchAll │ ├── matchAll.spec.ts │ └── matchAll.ts ├── max │ ├── max.spec.ts │ └── max.ts ├── memoize │ ├── BENCHMARK.md │ ├── memoize.benchmark.ts │ ├── memoize.spec.ts │ └── memoize.ts ├── min │ ├── min.spec.ts │ └── min.ts ├── omit │ ├── BENCHMARK.md │ ├── omit.benchmark.ts │ ├── omit.spec.ts │ └── omit.ts ├── parseModules.ts ├── percentile │ ├── percentile.spec.ts │ └── percentile.ts ├── pick │ ├── BENCHMARK.md │ ├── pick.benchmark.ts │ ├── pick.spec.ts │ └── pick.ts ├── promisePool │ ├── promisePool.spec.ts │ └── promisePool.ts ├── promiseTimeout │ ├── promiseTimeout.spec.ts │ └── promiseTimeout.ts ├── random │ ├── random.spec.ts │ └── random.ts ├── randomString │ ├── randomString.spec.ts │ └── randomString.ts ├── range │ ├── range.spec.ts │ └── range.ts ├── roundTo │ ├── roundTo.spec.ts │ └── roundTo.ts ├── sample │ ├── sample.spec.ts │ └── sample.ts ├── shuffle │ ├── shuffle.spec.ts │ └── shuffle.ts ├── sleep │ ├── sleep.spec.ts │ └── sleep.ts ├── slugify │ ├── slugify.spec.ts │ └── slugify.ts ├── sum │ ├── sum.spec.ts │ └── sum.ts ├── testHelpers.ts ├── throttle │ ├── throttle.spec.ts │ └── throttle.ts ├── toMap │ ├── toMap.spec.ts │ └── toMap.ts ├── typeHelpers.ts ├── unflatten │ ├── unflatten.spec.ts │ └── unflatten.ts └── unique │ ├── BENCHMARK.md │ ├── unique.benchmark.ts │ ├── unique.spec.ts │ └── unique.ts ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "imageSize": 75, 3 | "projectName": "flocky", 4 | "projectOwner": "devoxa", 5 | "repoType": "github", 6 | "repoHost": "https://github.com", 7 | "skipCi": true, 8 | "contributors": [ 9 | { 10 | "login": "queicherius", 11 | "name": "David Reeß", 12 | "avatar_url": "https://avatars3.githubusercontent.com/u/4615516?v=4", 13 | "profile": "https://www.david-reess.de", 14 | "contributions": ["code", "doc", "test"] 15 | }, 16 | { 17 | "login": "atjeff", 18 | "name": "Jeff Hage", 19 | "avatar_url": "https://avatars1.githubusercontent.com/u/10563763?v=4", 20 | "profile": "https://github.com/atjeff", 21 | "contributions": ["code"] 22 | }, 23 | { 24 | "login": "darthmaim", 25 | "name": "darthmaim", 26 | "avatar_url": "https://avatars2.githubusercontent.com/u/2511547?v=4", 27 | "profile": "https://gw2treasures.com/", 28 | "contributions": ["code"] 29 | } 30 | ], 31 | "files": ["README.md"], 32 | "contributorsPerLine": 7 33 | } 34 | -------------------------------------------------------------------------------- /.github/.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge] 4 | delete_branch_on_merge = true 5 | priority_merge_label = "priority-automerge" 6 | 7 | [merge.automerge_dependencies] 8 | usernames = ["renovate"] 9 | versions = ["minor", "patch"] 10 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>devoxa/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test-and-build: 15 | name: 'Test & build' 16 | runs-on: buildjet-4vcpu-ubuntu-2204 17 | timeout-minutes: 30 18 | 19 | steps: 20 | - name: 'Checkout the repository' 21 | uses: actions/checkout@v4 22 | 23 | - name: 'Setup Node.JS' 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: '20.19.0' 27 | 28 | - name: 'Cache dependencies' 29 | uses: buildjet/cache@v4 30 | with: 31 | path: '**/node_modules' 32 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 33 | 34 | - name: 'Install dependencies' 35 | run: yarn install --frozen-lockfile 36 | 37 | - name: 'Run tests' 38 | run: yarn test --coverage 39 | 40 | - name: 'Save test coverage' 41 | uses: codecov/codecov-action@v5 42 | 43 | - name: 'Check code formatting' 44 | run: yarn format:check 45 | 46 | - name: 'Run linter' 47 | run: yarn lint 48 | 49 | - name: 'Build package' 50 | run: yarn build 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Output 2 | dist/ 3 | 4 | # Tests 5 | coverage/ 6 | 7 | # Dependencies 8 | node_modules/ 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Development Environments 13 | .history/ 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Tests 2 | coverage/ 3 | 4 | # Dependencies 5 | node_modules/ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Development Environments 10 | .history/ 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Devoxa Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | flocky 4 |

5 | 6 | 7 |

8 | A utility library with clarity and efficiency at the core and no dependencies 9 |

10 | 11 | 12 |

13 | 14 | Package Version 18 | 19 | 20 | 21 | Build Status 25 | 26 | 27 | 28 | Code Coverage 32 | 33 |

34 | 35 | 36 |

37 | Installation • 38 | Usage • 39 | API Reference • 40 | Contributors • 41 | License 42 |

43 | 44 |
45 | 46 | ## Installation 47 | 48 | ```bash 49 | yarn add @devoxa/flocky 50 | ``` 51 | 52 | ## Usage 53 | 54 | ```ts 55 | import { sum } from '@devoxa/flocky' 56 | sum([1, 2, 3]) 57 | // -> 6 58 | ``` 59 | 60 | ## API Reference 61 | 62 | 63 | 64 | ### average(array) 65 | 66 | Compute the average of the values in an array. 67 | 68 | ```js 69 | flocky.average([1, 4, 2, -4, 0]) 70 | // -> 0.6 71 | ``` 72 | 73 | [Source](./src/average/average.ts) • Minify: 77 B • Minify & GZIP: 79 B 74 | 75 | ### chunk(array, size) 76 | 77 | Split an array of elements into groups of `size`. 78 | If the array can't be split evenly, the final chunk will contain the remaining elements. 79 | 80 | ```js 81 | flocky.chunk([1, 2, 3, 4, 5, 6, 7], 3) 82 | // -> [[1, 2, 3], [4, 5, 6], [7]] 83 | ``` 84 | 85 | [Source](./src/chunk/chunk.ts) • Minify: 105 B • Minify & GZIP: 101 B 86 | 87 | ### clamp(value, min, max) 88 | 89 | Clamps a value within a minimum and maximum range (inclusive). 90 | 91 | ```js 92 | flocky.clamp(3, 0, 5) 93 | // -> 3 94 | 95 | flocky.clamp(10, 0, 5) 96 | // -> 5 97 | 98 | flocky.clamp(-10, 0, 5) 99 | // -> 0 100 | ``` 101 | 102 | [Source](./src/clamp/clamp.ts) • Minify: 69 B • Minify & GZIP: 66 B 103 | 104 | ### clone(value) 105 | 106 | Create a deep clone of `value`. 107 | This method only supports types native to JSON, so all primitive types, arrays and objects. 108 | 109 | ```js 110 | const original = [{ a: 1 }, { b: 2 }] 111 | const clone = flocky.clone(original) 112 | original[0] === clone[0] 113 | // -> false 114 | ``` 115 | 116 | [Source](./src/clone/clone.ts) • Minify: 69 B • Minify & GZIP: 69 B 117 | 118 | ### compact(array) 119 | 120 | Create an array with all falsy (`undefined`, `null`, `false`, `0`, `NaN`, `''`) values removed. 121 | 122 | ```js 123 | flocky.compact([1, 2, 3, null, 4, false, 0, NaN, 5, '']) 124 | // -> [1, 2, 3, 4, 5] 125 | ``` 126 | 127 | [Source](./src/compact/compact.ts) • Minify: 61 B • Minify & GZIP: 64 B 128 | 129 | ### debounce(func, wait) 130 | 131 | Create a debounced function that delays invoking `func` until `wait` milliseconds 132 | have elapsed since the last time the debounced function was invoked. 133 | 134 | ```js 135 | const func = () => console.log('Heavy processing happening') 136 | const debouncedFunc = flocky.debounce(func, 250) 137 | ``` 138 | 139 | [Source](./src/debounce/debounce.ts) • Minify: 131 B • Minify & GZIP: 113 B 140 | 141 | ### duplicates(array, identity?) 142 | 143 | Create a version of an array, in which only the duplicated elements are kept. 144 | The order of result values is determined by the order they occur in the array. 145 | Can be passed an optional `identity` function to select the identifying part of objects. 146 | 147 | ```js 148 | flocky.duplicates([1, 1, 2, 4, 2, 1, 6]) 149 | // -> [1, 2, 1] 150 | 151 | flocky.duplicates(['foo', 'bar', 'foo', 'foobar']) 152 | // -> ['foo'] 153 | 154 | const input = [{ id: 1, a: 1 }, { id: 1, a: 2 }, { id: 2, a: 3 }, { id: 1, a: 4 }] 155 | flocky.duplicates(input, (element) => element.id) 156 | // -> [{ id: 1, a: 2 }, { id: 1, a: 4 }] 157 | ``` 158 | 159 | [Source](./src/duplicates/duplicates.ts) • Minify: 277 B • Minify & GZIP: 147 B 160 | 161 | ### escapeRegExp(string) 162 | 163 | Escape special characters in a string for use in a regular expression. 164 | 165 | ```js 166 | flocky.escapeRegExp('Hey. (1 + 1 = 2)') 167 | // -> 'Hey\\. \\(1 \\+ 1 = 2\\)' 168 | ``` 169 | 170 | [Source](./src/escapeRegExp/escapeRegExp.ts) • Minify: 93 B • Minify & GZIP: 90 B 171 | 172 | ### flatten(object) 173 | 174 | Flattens a nested object into an object with dot notation keys. 175 | 176 | ```js 177 | flocky.flatten({ a: { b: 1, c: 2 }, d: { e: { f: 3 } } }) 178 | // -> { 'a.b': 1, 'a.c': 2, 'd.e.f': 3 } 179 | ``` 180 | 181 | [Source](./src/flatten/flatten.ts) • Minify: 172 B • Minify & GZIP: 136 B 182 | 183 | ### get(object, path, defaultValue?) 184 | 185 | Get the value at a `path` of an `object` (with an optional `defaultValue`) 186 | 187 | :warning: **Using this method will ignore type information, and you will have 188 | to type the return type yourself. If you can, it is always better to access 189 | properties directly, for example with the "optional chaining" operator.** 190 | 191 | ```js 192 | const object = {a: {b: {c: 1}}} 193 | flocky.get(object, 'a.b.c') 194 | // -> 1 195 | 196 | const object = {a: {b: {c: 1}}} 197 | flocky.get(object, 'x.x.x') 198 | // -> undefined 199 | 200 | const object = {a: {b: {c: 1}}} 201 | flocky.get(object, 'x.x.x', 'default') 202 | // -> 'default' 203 | ``` 204 | 205 | [Source](./src/get/get.ts) • [Benchmark](./src/get/BENCHMARK.md) • Minify: 424 B • Minify & GZIP: 266 B 206 | 207 | ### hash(data) 208 | 209 | Create a hashed string representation of the passed in data. 210 | 211 | :warning: **This function is not cryptographically secure, use 212 | [`Argon2id`, `scrypt` or `bcrypt`](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#password-hashing-algorithms) 213 | for anything security related.** 214 | 215 | ```js 216 | flocky.hash('some really long string') 217 | // -> 'x1nr7uiv' 218 | 219 | flocky.hash({id: 'AAA', name: 'BBB'}) 220 | // -> 'x16mynva' 221 | ``` 222 | 223 |
224 | Implementation Details 225 | 226 | This method uses Murmur3 because it is small, fast and has fairly good 227 | collision characteristics (about 1 in 36000). 228 | 229 | - https://softwareengineering.stackexchange.com/questions/49550 230 | - https://github.com/VowpalWabbit/vowpal_wabbit/wiki/murmur2-vs-murmur3 231 | - https://en.wikipedia.org/wiki/MurmurHash 232 | - https://github.com/whitequark/murmurhash3-js/blob/master/murmurhash3.js 233 |
234 | 235 | [Source](./src/hash/hash.ts) • [Benchmark](./src/hash/BENCHMARK.md) • Minify: 552 B • Minify & GZIP: 332 B 236 | 237 | ### identifier() 238 | 239 | Generate a random identifier with UUID v4 format. 240 | 241 | ```js 242 | flocky.identifier() 243 | // -> 'bfc8d57e-b9ab-4245-836e-d1fd99602e30' 244 | ``` 245 | 246 | [Source](./src/identifier/identifier.ts) • Minify: 275 B • Minify & GZIP: 196 B 247 | 248 | ### matchAll(regExp, string) 249 | 250 | Find all matches of a regular expression in a string. 251 | 252 | ```js 253 | flocky.matchAll(/f(o+)/g, 'foo bar baz foooo bar') 254 | // -> [ 255 | // -> { match: 'foo', subMatches: ['oo'], index: 0 }, 256 | // -> { match: 'foooo', subMatches: ['oooo'], index: 12 }, 257 | // -> ] 258 | ``` 259 | 260 | [Source](./src/matchAll/matchAll.ts) • Minify: 178 B • Minify & GZIP: 133 B 261 | 262 | ### max(array) 263 | 264 | Compute the maximum of the values in an array. 265 | 266 | ```js 267 | flocky.max([1, 4, 2, -3, 0]) 268 | // -> 4 269 | ``` 270 | 271 | [Source](./src/max/max.ts) • Minify: 58 B • Minify & GZIP: 63 B 272 | 273 | ### memoize(func, options?) 274 | 275 | Create a function that memoizes the return value of `func`. 276 | 277 | ```js 278 | const func = (a, b) => a + b 279 | const memoizedFunc = flocky.memoize(func) 280 | const memoizedFuncWithTtl = flocky.memoize(func, { ttl: 30 * 1000 }) 281 | memoizedFunc(1, 2) 282 | // -> 3 283 | ``` 284 | 285 |
286 | Implementation Details 287 | 288 | This method's implementation is based on [fast-memoize](https://github.com/caiogondim/fast-memoize.js), 289 | with some improvements for variadic performance and additional support for a TTL based cache. 290 |
291 | 292 | [Source](./src/memoize/memoize.ts) • [Benchmark](./src/memoize/BENCHMARK.md) • Minify: 962 B • Minify & GZIP: 441 B 293 | 294 | ### min(array) 295 | 296 | Compute the minimum of the values in an array. 297 | 298 | ```js 299 | flocky.min([1, 4, 2, -3, 0]) 300 | // -> -3 301 | ``` 302 | 303 | [Source](./src/min/min.ts) • Minify: 58 B • Minify & GZIP: 63 B 304 | 305 | ### omit(object, keys) 306 | 307 | Create an object composed of all existing keys that are not specified in `keys`. 308 | 309 | ```js 310 | const object = { a: 1, b: 2, c: 3 } 311 | flocky.omit(object, ['a']) 312 | // -> { b: 2, c: 3 } 313 | ``` 314 | 315 | [Source](./src/omit/omit.ts) • [Benchmark](./src/omit/BENCHMARK.md) • Minify: 143 B • Minify & GZIP: 129 B 316 | 317 | ### percentile(array, k) 318 | 319 | Compute the kth percentile of the values in an array. 320 | 321 | ```js 322 | flocky.percentile([90, 85, 65, 72, 82, 96, 70, 79, 68, 84], 0.9) 323 | // -> 90.6 324 | ``` 325 | 326 | [Source](./src/percentile/percentile.ts) • Minify: 217 B • Minify & GZIP: 156 B 327 | 328 | ### pick(object, keys) 329 | 330 | Create an object composed of the specified `keys`. 331 | 332 | ```js 333 | const object = { a: 1, b: 2, c: 3 } 334 | flocky.pick(object, ['a', 'c']) 335 | // -> { a: 1, c: 3 } 336 | ``` 337 | 338 | [Source](./src/pick/pick.ts) • [Benchmark](./src/pick/BENCHMARK.md) • Minify: 97 B • Minify & GZIP: 93 B 339 | 340 | ### promisePool(promiseFunctions, limit) 341 | 342 | Run multiple promise-returning functions in parallel with limited concurrency. 343 | 344 | ```js 345 | await flocky.promisePool([ 346 | () => Promise.resolve(1), 347 | () => Promise.resolve(2), 348 | () => Promise.resolve(3), 349 | () => Promise.resolve(4), 350 | ], 2) 351 | // -> [1, 2, 3, 4] 352 | ``` 353 | 354 | [Source](./src/promisePool/promisePool.ts) • Minify: 182 B • Minify & GZIP: 142 B 355 | 356 | ### promiseTimeout(promise, timeoutMs) 357 | 358 | Reject a promise if it does not resolve within `timeoutMs`. 359 | 360 | :warning: **When the timeout is hit, a promise rejection will be thrown. However, 361 | since promises are not cancellable, the execution of the promise itself will continue 362 | until it resolves or rejects.** 363 | 364 | ```js 365 | await flocky.promiseTimeout(Promise.resolve(1), 10) 366 | // -> 1 367 | ``` 368 | 369 | [Source](./src/promiseTimeout/promiseTimeout.ts) • Minify: 305 B • Minify & GZIP: 181 B 370 | 371 | ### random(lower, upper, float?) 372 | 373 | Generate a random number between `lower` and `upper` (inclusive). 374 | If `float` is true or `lower` or `upper` is a float, a float is returned instead of an integer. 375 | 376 | ```js 377 | flocky.random(1, 10) 378 | // -> 8 379 | 380 | flocky.random(1, 20, true) 381 | // -> 14.94849340769861 382 | 383 | flocky.random(2.5, 3.5) 384 | // -> 3.2341312319841373 385 | ``` 386 | 387 | [Source](./src/random/random.ts) • Minify: 219 B • Minify & GZIP: 128 B 388 | 389 | ### randomString(length) 390 | 391 | Generate a random alphanumeric string with `length` characters. 392 | 393 | ```js 394 | flocky.randomString(5) 395 | // -> 'tfl0g' 396 | ``` 397 | 398 | [Source](./src/randomString/randomString.ts) • Minify: 230 B • Minify & GZIP: 201 B 399 | 400 | ### range(start, end, step?) 401 | 402 | Generate an array of numbers progressing from `start` up to and including `end`. 403 | 404 | ```js 405 | flocky.range(0, 5) 406 | // -> [0, 1, 2, 3, 4, 5] 407 | 408 | flocky.range(-5, -10) 409 | // -> [-5, -6, -7, -8, -9, -10] 410 | 411 | flocky.range(-6, -12, 2) 412 | // -> [-6, -8, -10, -12] 413 | ``` 414 | 415 | [Source](./src/range/range.ts) • Minify: 131 B • Minify & GZIP: 111 B 416 | 417 | ### roundTo(number, precision) 418 | 419 | Round a floating point number to `precision` decimal places. 420 | 421 | ```js 422 | flocky.roundTo(3.141592653589, 4) 423 | // -> 3.1416 424 | 425 | flocky.roundTo(1.005, 2) 426 | // -> 1.01 427 | 428 | flocky.roundTo(1111.1, -2) 429 | // -> 1100 430 | ``` 431 | 432 |
433 | Implementation Details 434 | 435 | This method avoids floating-point errors by adjusting the exponent part of 436 | the string representation of a number instead of multiplying and dividing 437 | with powers of 10. The implementation is based on [this example](https://stackoverflow.com/a/60098416) 438 | by Lam Wei Li. 439 |
440 | 441 | [Source](./src/roundTo/roundTo.ts) • Minify: 209 B • Minify & GZIP: 150 B 442 | 443 | ### sample(array) 444 | 445 | Get a random element from the `array`. 446 | 447 | ```js 448 | flocky.sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 449 | // -> 8 450 | ``` 451 | 452 | [Source](./src/sample/sample.ts) • Minify: 79 B • Minify & GZIP: 79 B 453 | 454 | ### shuffle(array) 455 | 456 | Create an array of shuffled values. 457 | 458 | ```js 459 | flocky.shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 460 | // -> [3, 7, 2, 1, 10, 4, 6, 9, 5, 8] 461 | ``` 462 | 463 |
464 | Implementation Details 465 | 466 | This method uses a modern version of the 467 | [Fisher-Yates shuffle](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm). 468 |
469 | 470 | [Source](./src/shuffle/shuffle.ts) • Minify: 152 B • Minify & GZIP: 131 B 471 | 472 | ### sleep(ms) 473 | 474 | Return a promise that waits for `ms` milliseconds before resolving. 475 | 476 | ```js 477 | await flocky.sleep(25) 478 | ``` 479 | 480 | [Source](./src/sleep/sleep.ts) • Minify: 73 B • Minify & GZIP: 78 B 481 | 482 | ### slugify(string) 483 | 484 | Generate a URL-safe slug of a string. 485 | 486 | ```js 487 | flocky.slugify(' Issue #123 is _important_! :)') 488 | // -> 'issue-123-is-important' 489 | ``` 490 | 491 | [Source](./src/slugify/slugify.ts) • Minify: 114 B • Minify & GZIP: 104 B 492 | 493 | ### sum(array) 494 | 495 | Compute the sum of the values in an array. 496 | 497 | ```js 498 | flocky.sum([1, 4, 2, -4, 0]) 499 | // -> 3 500 | ``` 501 | 502 | [Source](./src/sum/sum.ts) • Minify: 60 B • Minify & GZIP: 67 B 503 | 504 | ### throttle(func, wait) 505 | 506 | Create a throttled function that invokes `func` at most every `wait` milliseconds. 507 | If the invocation is throttled, `func` will be invoked with the last arguments provided. 508 | 509 | ```js 510 | const func = () => console.log('Heavy processing happening') 511 | const throttledFunc = flocky.throttle(func, 250) 512 | ``` 513 | 514 | [Source](./src/throttle/throttle.ts) • Minify: 209 B • Minify & GZIP: 156 B 515 | 516 | ### toMap(array, key, target?) 517 | 518 | Create a lookup map out of an `array` of objects, with a lookup `key` and an optional `target`. 519 | 520 | ```js 521 | flocky.toMap( 522 | [ 523 | { id: 1, name: 'Stanley', age: 64 }, 524 | { id: 2, name: 'Juliet', age: 57 }, 525 | { id: 3, name: 'Alex', age: 19 } 526 | ], 527 | 'id' 528 | ) 529 | // -> { 530 | // -> 1: { id: 1, name: 'Stanley', age: 64 }, 531 | // -> 2: { id: 2, name: 'Juliet', age: 57 }, 532 | // -> 3: { id: 3, name: 'Alex', age: 19 } 533 | // -> } 534 | 535 | flocky.toMap( 536 | [ 537 | { id: 1, name: 'Stanley', age: 64 }, 538 | { id: 2, name: 'Juliet', age: 57 }, 539 | { id: 3, name: 'Alex', age: 19 } 540 | ], 541 | 'name', 542 | 'age' 543 | ) 544 | // -> { Stanley: 64, Juliet: 57, Alex: 19 } 545 | ``` 546 | 547 | [Source](./src/toMap/toMap.ts) • Minify: 95 B • Minify & GZIP: 95 B 548 | 549 | ### unflatten(object) 550 | 551 | Unflattens an object with dot notation keys into a nested object. 552 | 553 | ```js 554 | flocky.unflatten({ 'a.b': 1, 'a.c': 2, 'd.e.f': 3 }) 555 | // -> { a: { b: 1, c: 2 }, d: { e: { f: 3 } } } 556 | ``` 557 | 558 | [Source](./src/unflatten/unflatten.ts) • Minify: 199 B • Minify & GZIP: 145 B 559 | 560 | ### unique(array, identity?) 561 | 562 | Create a duplicate-free version of an array, in which only the first occurrence of each element is kept. 563 | The order of result values is determined by the order they occur in the array. 564 | Can be passed an optional `identity` function to select the identifying part of objects. 565 | 566 | ```js 567 | flocky.unique([1, 1, 2, 4, 2, 1, 6]) 568 | // -> [1, 2, 4, 6] 569 | 570 | flocky.unique(['foo', 'bar', 'foo', 'foobar']) 571 | // -> ['foo', 'bar', 'foobar'] 572 | 573 | const input = [{ id: 1, a: 1 }, { id: 1, a: 2 }, { id: 2, a: 3 }, { id: 1, a: 4 }] 574 | flocky.unique(input, (element) => element.id) 575 | // -> [{ id: 1, a: 1 }, { id: 2, a: 3 }] 576 | ``` 577 | 578 | [Source](./src/unique/unique.ts) • [Benchmark](./src/unique/BENCHMARK.md) • Minify: 238 B • Minify & GZIP: 153 B 579 | 580 | 581 | 582 | 583 | ## Contributors 584 | 585 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 |

David Reeß

💻 📖 ⚠️

Jeff Hage

💻

darthmaim

💻
597 | 598 | 599 | 600 | 601 | 602 | 603 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) 604 | specification. Contributions of any kind welcome! 605 | 606 | ## License 607 | 608 | MIT 609 | -------------------------------------------------------------------------------- /benchmark.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | find src/ -name "*.benchmark.ts" -type f | while read -r file; do 5 | echo ">> Running benchmark: $file" 6 | ./node_modules/.bin/ts-node --transpile-only -O '{"module":"commonjs"}' $file 7 | done 8 | 9 | echo ">> All benchmarks completed" 10 | -------------------------------------------------------------------------------- /dvx.json: -------------------------------------------------------------------------------- 1 | { 2 | "npmDependencyRules": [ 3 | // Allow dependencies for benchmark comparisons that are usually explicitly denied 4 | { "action": "allow", "name": "fast-memoize", "levels": ["dev"] }, 5 | { "action": "allow", "name": "lodash", "levels": ["dev"] } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const { build } = require('esbuild') 2 | const fastGlob = require('fast-glob') 3 | 4 | // Output the commonjs file 5 | build({ 6 | entryPoints: ['./src/index.ts'], 7 | outfile: 'dist/index.js', 8 | format: 'cjs', 9 | bundle: true, 10 | sourcemap: true, 11 | target: 'node20.9', 12 | }).catch(() => process.exit(1)) 13 | 14 | // Output the es modules files 15 | build({ 16 | entryPoints: fastGlob.sync('./src/**/*.ts'), 17 | outdir: 'dist/esm', 18 | format: 'esm', 19 | sourcemap: true, 20 | target: 'chrome90', 21 | }).catch(() => process.exit(1)) 22 | -------------------------------------------------------------------------------- /eslint.config.cjs: -------------------------------------------------------------------------------- 1 | const config = require('@devoxa/eslint-config') 2 | 3 | module.exports = config({ 4 | ignoreFiles: ['.gitignore'], 5 | }) 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.tsx?$': ['@swc/jest', { jsc: { transform: { react: { runtime: 'automatic' } } } }], 4 | }, 5 | coverageProvider: 'v8', 6 | testEnvironment: 'jsdom', 7 | modulePathIgnorePatterns: ['/dist'], 8 | collectCoverageFrom: [ 9 | '/src/**/*', 10 | '!/src/benchmarkHelper.ts', 11 | '!/src/generateDocs.ts', 12 | '!/src/parseModules.ts', 13 | '!/src/testHelpers.ts', 14 | '!/src/typeHelpers.ts', 15 | '!/src/**/*.benchmark.ts', 16 | ], 17 | coverageThreshold: { global: { branches: 100, functions: 100, lines: 100, statements: 100 } }, 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@devoxa/flocky", 3 | "description": "A utility library with clarity and efficiency at the core and no dependencies", 4 | "version": "3.1.0", 5 | "main": "dist/index.js", 6 | "module": "dist/esm/index.js", 7 | "license": "MIT", 8 | "repository": { 9 | "url": "https://github.com/devoxa/flocky" 10 | }, 11 | "scripts": { 12 | "test": "jest", 13 | "benchmark": "sh benchmark.sh", 14 | "format": "prettier --ignore-path='.gitignore' --list-different --write .", 15 | "format:check": "prettier --ignore-path='.gitignore' --check .", 16 | "lint": "eslint '{src,tests}/**/*.ts'", 17 | "build": "yarn build:esbuild && yarn build:docs", 18 | "build:esbuild": "rm -rf dist/ && node esbuild.js && tsc --emitDeclarationOnly", 19 | "build:docs": "ts-node -O '{\"module\":\"commonjs\"}' src/generateDocs.ts", 20 | "preversion": "yarn build" 21 | }, 22 | "prettier": "@devoxa/prettier-config", 23 | "devDependencies": { 24 | "@devoxa/eslint-config": "4.0.2", 25 | "@devoxa/prettier-config": "2.0.3", 26 | "@swc/core": "1.11.29", 27 | "@swc/jest": "0.2.38", 28 | "@types/benchmark": "2.1.5", 29 | "@types/jest": "29.5.14", 30 | "@types/lodash": "4.17.15", 31 | "@types/node": "20.9.5", 32 | "@types/pako": "2.0.3", 33 | "benchmark": "2.1.4", 34 | "esbuild": "0.25.1", 35 | "eslint": "9.24.0", 36 | "fast-glob": "3.3.3", 37 | "fast-memoize": "2.5.2", 38 | "filesize": "10.1.6", 39 | "jest": "29.7.0", 40 | "jest-environment-jsdom": "29.7.0", 41 | "lodash": "4.17.21", 42 | "pako": "2.1.0", 43 | "prettier": "3.5.3", 44 | "terser": "5.39.1", 45 | "ts-node": "10.9.2", 46 | "typescript": "5.8.2" 47 | }, 48 | "publishConfig": { 49 | "access": "public" 50 | }, 51 | "volta": { 52 | "node": "20.9.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/average/average.spec.ts: -------------------------------------------------------------------------------- 1 | import { average } from './average' 2 | 3 | describe('average', () => { 4 | test('calculates the average of an array of numbers', () => { 5 | expect(average([1, 4, 2, 0])).toEqual(1.75) 6 | expect(average([9, 4, 3, 7, 79, -60])).toEqual(7) 7 | expect(average([])).toEqual(NaN) 8 | }) 9 | 10 | test('does not mutate the input', () => { 11 | const input = [1, 2, 3] 12 | average(input) 13 | expect(input).toEqual([1, 2, 3]) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/average/average.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### average(array) 3 | * 4 | * Compute the average of the values in an array. 5 | * 6 | * ```js 7 | * flocky.average([1, 4, 2, -4, 0]) 8 | * // -> 0.6 9 | * ``` 10 | */ 11 | 12 | export function average(array: Array): number { 13 | return array.reduce((a, b) => a + b, 0) / array.length 14 | } 15 | -------------------------------------------------------------------------------- /src/benchmarkHelper.ts: -------------------------------------------------------------------------------- 1 | import { Event, Suite } from 'benchmark' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import { max } from './max/max' 5 | import { unique } from './unique/unique' 6 | 7 | interface BenchmarkSample { 8 | library: string 9 | input: string 10 | func: () => void 11 | } 12 | 13 | interface BenchmarkResult { 14 | library: string 15 | input: string 16 | opsPerSec: number 17 | } 18 | 19 | export class Benchmark { 20 | private readonly name: string 21 | private suite: Suite 22 | private results: Array 23 | 24 | constructor(name: string) { 25 | this.name = name 26 | this.suite = new Suite() 27 | this.results = [] 28 | } 29 | 30 | add(options: BenchmarkSample): void { 31 | this.suite.add(options.library + ' | ' + options.input, options.func) 32 | } 33 | 34 | run(): void { 35 | this.suite.on('cycle', (event: Event) => this.addResult(event)) 36 | this.suite.on('complete', () => this.writeResults()) 37 | this.suite.run({ async: true }) 38 | } 39 | 40 | // Parse the string output of BenchmarkJS because that seems easier than trying 41 | // to understand their API and doing all the calculations ourselves... 42 | addResult(event: Event): void { 43 | const eventString = String(event.target) // eslint-disable-line @typescript-eslint/no-base-to-string 44 | 45 | const [name, runtimeInfo] = eventString.split(' x ') 46 | const [library, input] = name.split(' | ') 47 | 48 | let [opsPerSec] = runtimeInfo.split(' ops/sec') 49 | opsPerSec = opsPerSec.replace(/,/g, '') 50 | 51 | this.results.push({ 52 | library, 53 | input, 54 | opsPerSec: parseInt(opsPerSec, 10), 55 | }) 56 | } 57 | 58 | writeResults(): void { 59 | // TODO: Add system information at the bottom 60 | 61 | const content = [ 62 | `### Benchmark for \`${this.name}\``, 63 | '', 64 | `[Source for this benchmark](./${this.name}.benchmark.ts)`, 65 | '', 66 | this.generateResultTable(), 67 | '', 68 | [ 69 | '', 70 | 'Generated at ' + new Date().toISOString().replace(/T.*$/, ''), 71 | ' with Node.JS ' + process.version, 72 | '', 73 | ].join(''), 74 | ].join('\n') 75 | 76 | fs.writeFileSync(path.join(__dirname, `./${this.name}/BENCHMARK.md`), content, 'utf-8') 77 | } 78 | 79 | generateResultTable(): string { 80 | const table: Array = [] 81 | const inputs = unique(this.results.map((x) => x.input)) 82 | const libraries = unique(this.results.map((x) => x.library)) 83 | 84 | // Generate the header (libraries from left to right) 85 | table.push(`| | ${libraries.join(' | ')} |`) 86 | table.push(`| --- | ${libraries.map(() => '---').join(' | ')} |`) 87 | 88 | // Generate the table content (inputs from top to bottom) 89 | inputs.forEach((input) => { 90 | const inputResults = this.results.filter((x) => x.input === input) 91 | const bestResult = max(inputResults.map((x) => x.opsPerSec)) 92 | 93 | const formattedInputResults = inputResults.map((result) => { 94 | const percent = (result.opsPerSec / bestResult) * 100 95 | const formattedOps = result.opsPerSec.toLocaleString() 96 | 97 | return result.opsPerSec === bestResult 98 | ? `**${formattedOps} ops/sec (100.00%)**` 99 | : `${formattedOps} ops/sec (${percent.toFixed(2)}%)` 100 | }) 101 | 102 | table.push(`| ${input} | ${formattedInputResults.join(' | ')} |`) 103 | }) 104 | 105 | return table.join('\n') 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/chunk/chunk.spec.ts: -------------------------------------------------------------------------------- 1 | import { chunk } from './chunk' 2 | 3 | describe('chunk', () => { 4 | test('splits an array of numbers into chunks', () => { 5 | const chunks = chunk([1, 2, 3, 4, 5, 6, 7], 3) 6 | expect(chunks).toEqual([[1, 2, 3], [4, 5, 6], [7]]) 7 | }) 8 | 9 | test('splits an array of strings into chunks', () => { 10 | expect(chunk(['1', '2', '3', '4', '5', '6', '7'], 2)).toEqual([ 11 | ['1', '2'], 12 | ['3', '4'], 13 | ['5', '6'], 14 | ['7'], 15 | ]) 16 | }) 17 | 18 | test('splits an array of objects into chunks', () => { 19 | expect( 20 | chunk([{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }, { a: '5' }, { a: '6' }, { a: '7' }], 4) 21 | ).toEqual([ 22 | [{ a: '1' }, { a: '2' }, { a: '3' }, { a: '4' }], 23 | [{ a: '5' }, { a: '6' }, { a: '7' }], 24 | ]) 25 | }) 26 | 27 | test('does not mutate the input', () => { 28 | const input = [1, 2, 3] 29 | chunk(input, 1) 30 | expect(input).toEqual([1, 2, 3]) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/chunk/chunk.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### chunk(array, size) 3 | * 4 | * Split an array of elements into groups of `size`. 5 | * If the array can't be split evenly, the final chunk will contain the remaining elements. 6 | * 7 | * ```js 8 | * flocky.chunk([1, 2, 3, 4, 5, 6, 7], 3) 9 | * // -> [[1, 2, 3], [4, 5, 6], [7]] 10 | * ``` 11 | */ 12 | 13 | export function chunk(array: Array, size: number): Array> { 14 | const chunked: Array> = [] 15 | 16 | for (let i = 0; i < array.length; i += size) { 17 | chunked.push(array.slice(i, i + size)) 18 | } 19 | 20 | return chunked 21 | } 22 | -------------------------------------------------------------------------------- /src/clamp/clamp.spec.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from './clamp' 2 | 3 | describe('clamp', () => { 4 | test('clamps a value between a minimum and maximum range (inside range)', () => { 5 | expect(clamp(0, 0, 5)).toEqual(0) 6 | expect(clamp(1, 0, 5)).toEqual(1) 7 | expect(clamp(3, 0, 5)).toEqual(3) 8 | expect(clamp(5, 0, 5)).toEqual(5) 9 | }) 10 | 11 | test('clamps a value between a minimum and maximum range (below min)', () => { 12 | expect(clamp(-0.1, 0, 5)).toEqual(0) 13 | expect(clamp(-1, 0, 5)).toEqual(0) 14 | expect(clamp(-10, 0, 5)).toEqual(0) 15 | }) 16 | 17 | test('clamps a value between a minimum and maximum range (above max)', () => { 18 | expect(clamp(5.1, 0, 5)).toEqual(5) 19 | expect(clamp(6, 0, 5)).toEqual(5) 20 | expect(clamp(10, 0, 5)).toEqual(5) 21 | }) 22 | 23 | test('handles very large and small numbers', () => { 24 | expect(clamp(Number.MAX_SAFE_INTEGER, 0, 100)).toEqual(100) 25 | expect(clamp(Number.MIN_SAFE_INTEGER, -100, 0)).toEqual(-100) 26 | expect(clamp(0, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)).toEqual(0) 27 | }) 28 | 29 | test('handles cases where min equals max', () => { 30 | expect(clamp(0, 5, 5)).toEqual(5) 31 | expect(clamp(10, 5, 5)).toEqual(5) 32 | expect(clamp(-10, 5, 5)).toEqual(5) 33 | }) 34 | 35 | test('handles negative ranges correctly', () => { 36 | expect(clamp(-3, -5, -1)).toEqual(-3) 37 | expect(clamp(0, -5, -1)).toEqual(-1) 38 | expect(clamp(-10, -5, -1)).toEqual(-5) 39 | }) 40 | 41 | test('handles decimal numbers correctly', () => { 42 | expect(clamp(1.5, 1, 2)).toEqual(1.5) 43 | expect(clamp(0.5, 1, 2)).toEqual(1) 44 | expect(clamp(2.5, 1, 2)).toEqual(2) 45 | expect(clamp(1.5, 1.1, 1.9)).toEqual(1.5) 46 | }) 47 | 48 | test('handles invalid input where min is greater than max', () => { 49 | expect(clamp(3, 5, 0)).toEqual(0) 50 | expect(clamp(3, 10, 5)).toEqual(5) 51 | expect(clamp(-1, 2, -2)).toEqual(-2) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/clamp/clamp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### clamp(value, min, max) 3 | * 4 | * Clamps a value within a minimum and maximum range (inclusive). 5 | * 6 | * ```js 7 | * flocky.clamp(3, 0, 5) 8 | * // -> 3 9 | * 10 | * flocky.clamp(10, 0, 5) 11 | * // -> 5 12 | * 13 | * flocky.clamp(-10, 0, 5) 14 | * // -> 0 15 | * ``` 16 | */ 17 | 18 | export function clamp(value: number, min: number, max: number): number { 19 | return Math.min(Math.max(value, min), max) 20 | } 21 | -------------------------------------------------------------------------------- /src/clone/clone.spec.ts: -------------------------------------------------------------------------------- 1 | import { clone } from './clone' 2 | 3 | describe('clone', () => { 4 | test('clones primitive types', () => { 5 | expect(clone('AAA')).toEqual('AAA') 6 | expect(clone(123)).toEqual(123) 7 | expect(clone(true)).toEqual(true) 8 | expect(clone(null)).toEqual(null) 9 | 10 | // Handles undefined 11 | const x = { a: undefined, b: null } 12 | expect(clone(x)).toEqual({ b: null }) 13 | }) 14 | 15 | test('deep clones objects', () => { 16 | const original = { a: { b: 1 } } 17 | const cloned = clone(original) 18 | cloned.a.b = 2 19 | 20 | expect(original).toEqual({ a: { b: 1 } }) 21 | expect(cloned).toEqual({ a: { b: 2 } }) 22 | }) 23 | 24 | test('deep clones arrays', () => { 25 | const original = [{ a: 1 }, { b: 1 }] 26 | const cloned = clone(original) 27 | cloned[0].a = 2 28 | 29 | expect(original).toEqual([{ a: 1 }, { b: 1 }]) 30 | expect(cloned).toEqual([{ a: 2 }, { b: 1 }]) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/clone/clone.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from '../typeHelpers' 2 | 3 | /** 4 | * ### clone(value) 5 | * 6 | * Create a deep clone of `value`. 7 | * This method only supports types native to JSON, so all primitive types, arrays and objects. 8 | * 9 | * ```js 10 | * const original = [{ a: 1 }, { b: 2 }] 11 | * const clone = flocky.clone(original) 12 | * original[0] === clone[0] 13 | * // -> false 14 | * ``` 15 | */ 16 | 17 | export function clone(value: T): T { 18 | return JSON.parse(JSON.stringify(value)) as T 19 | } 20 | -------------------------------------------------------------------------------- /src/compact/compact.spec.ts: -------------------------------------------------------------------------------- 1 | import { compact } from './compact' 2 | 3 | describe('compact', () => { 4 | test('compacts numeric arrays', () => { 5 | const original = [1, false, 2, null, 3, 0, 4, '', 5, undefined] 6 | const compacted = compact(original) 7 | 8 | expect(original).toEqual([1, false, 2, null, 3, 0, 4, '', 5, undefined]) 9 | expect(compacted).toEqual([1, 2, 3, 4, 5]) 10 | }) 11 | 12 | test('compacts string arrays', () => { 13 | const original = ['a', false, 'b', null, 'c', 0, 'd', '', 'e', undefined] 14 | const compacted = compact(original) 15 | 16 | expect(original).toEqual(['a', false, 'b', null, 'c', 0, 'd', '', 'e', undefined]) 17 | expect(compacted).toEqual(['a', 'b', 'c', 'd', 'e']) 18 | }) 19 | 20 | test('compacts object arrays', () => { 21 | const original = [ 22 | { a: 1 }, 23 | false, 24 | { b: 1 }, 25 | null, 26 | { c: 1 }, 27 | 0, 28 | { d: 1 }, 29 | '', 30 | { e: 1 }, 31 | undefined, 32 | ] 33 | const compacted = compact(original) 34 | 35 | expect(original).toEqual([ 36 | { a: 1 }, 37 | false, 38 | { b: 1 }, 39 | null, 40 | { c: 1 }, 41 | 0, 42 | { d: 1 }, 43 | '', 44 | { e: 1 }, 45 | undefined, 46 | ]) 47 | expect(compacted).toEqual([{ a: 1 }, { b: 1 }, { c: 1 }, { d: 1 }, { e: 1 }]) 48 | }) 49 | 50 | test('compacts completely falsy arrays', () => { 51 | const original = [false, null, 0, NaN, undefined, ''] 52 | const compacted = compact(original) 53 | 54 | expect(original).toEqual([false, null, 0, NaN, undefined, '']) 55 | expect(compacted).toEqual([]) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /src/compact/compact.ts: -------------------------------------------------------------------------------- 1 | type Falsy = undefined | null | false | 0 | '' 2 | 3 | /** 4 | * ### compact(array) 5 | * 6 | * Create an array with all falsy (`undefined`, `null`, `false`, `0`, `NaN`, `''`) values removed. 7 | * 8 | * ```js 9 | * flocky.compact([1, 2, 3, null, 4, false, 0, NaN, 5, '']) 10 | * // -> [1, 2, 3, 4, 5] 11 | * ``` 12 | */ 13 | 14 | export function compact(array: Array): Array { 15 | return array.filter(Boolean) as Array 16 | } 17 | -------------------------------------------------------------------------------- /src/debounce/debounce.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '../sleep/sleep' 2 | import { debounce } from './debounce' 3 | 4 | describe('debounce', () => { 5 | test('debounces the function call', async () => { 6 | const func = jest.fn() 7 | 8 | const debouncedFunc = debounce(func, 25) 9 | debouncedFunc('a') 10 | debouncedFunc('ab') 11 | debouncedFunc('abc') 12 | debouncedFunc('abcd') 13 | 14 | expect(func.mock.calls).toEqual([]) 15 | 16 | await sleep(25) 17 | debouncedFunc('abcde') 18 | await sleep(25) 19 | 20 | expect(func.mock.calls).toEqual([['abcd'], ['abcde']]) 21 | }) 22 | 23 | test('has the correct type', () => { 24 | const func = (a: number, b: number): number => { 25 | return a + b 26 | } 27 | 28 | const debouncedFunc = debounce(func, 250) 29 | 30 | // @ts-expect-error The first argument has to be a number 31 | debouncedFunc('a', 2) 32 | 33 | // @ts-expect-error The second argument has to be a number 34 | debouncedFunc(2, 'a') 35 | 36 | // @ts-expect-error The return value is void 37 | debouncedFunc(2, 2)?.concat() 38 | 39 | // @ts-expect-error The return value is void 40 | debouncedFunc(2, 2)?.toFixed() 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/debounce/debounce.ts: -------------------------------------------------------------------------------- 1 | import { TAnyFunction } from '../typeHelpers' 2 | 3 | /** 4 | * ### debounce(func, wait) 5 | * 6 | * Create a debounced function that delays invoking `func` until `wait` milliseconds 7 | * have elapsed since the last time the debounced function was invoked. 8 | * 9 | * ```js 10 | * const func = () => console.log('Heavy processing happening') 11 | * const debouncedFunc = flocky.debounce(func, 250) 12 | * ``` 13 | */ 14 | 15 | type FunctionWithVoidReturn> = (...args: Parameters) => void 16 | 17 | export function debounce>( 18 | func: TFunc, 19 | wait: number 20 | ): FunctionWithVoidReturn { 21 | let timeoutID: NodeJS.Timeout | null = null 22 | 23 | return function (this: unknown, ...args: Array) { 24 | if (timeoutID) clearTimeout(timeoutID) 25 | timeoutID = setTimeout(() => func.apply(this, args), wait) 26 | } as FunctionWithVoidReturn 27 | } 28 | -------------------------------------------------------------------------------- /src/duplicates/duplicates.spec.ts: -------------------------------------------------------------------------------- 1 | import { duplicates } from './duplicates' 2 | 3 | describe('duplicates', () => { 4 | test('filters the duplicate occurrences of an array of numbers', () => { 5 | expect(duplicates([1, 4, 2, 0])).toEqual([]) 6 | expect(duplicates([1, 1, 4, 2, 0, 2, 0])).toEqual([1, 2, 0]) 7 | }) 8 | 9 | test('filters the duplicate occurrences of an array of strings', () => { 10 | expect(duplicates(['foo', 'bar', 'foo', 'foobar', 'foo', 'foo'])).toEqual(['foo', 'foo', 'foo']) 11 | }) 12 | 13 | test('filters the duplicate occurrences of an array of objects with an identity function', () => { 14 | const input = [ 15 | { id: 1, name: 'Foo1' }, 16 | { id: 2, name: 'Foo2' }, 17 | { id: 1, name: 'Foo3' }, 18 | { id: 3, name: 'Foo4' }, 19 | { id: 2, name: 'Foo5' }, 20 | ] 21 | 22 | const expected = [ 23 | { id: 1, name: 'Foo3' }, 24 | { id: 2, name: 'Foo5' }, 25 | ] 26 | 27 | expect(duplicates(input, (element) => element.id)).toEqual(expected) 28 | }) 29 | 30 | test('does not mutate the input', () => { 31 | const input = [1, 2, 3, 1] 32 | duplicates(input) 33 | expect(input).toEqual([1, 2, 3, 1]) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/duplicates/duplicates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### duplicates(array, identity?) 3 | * 4 | * Create a version of an array, in which only the duplicated elements are kept. 5 | * The order of result values is determined by the order they occur in the array. 6 | * Can be passed an optional `identity` function to select the identifying part of objects. 7 | * 8 | * ```js 9 | * flocky.duplicates([1, 1, 2, 4, 2, 1, 6]) 10 | * // -> [1, 2, 1] 11 | * 12 | * flocky.duplicates(['foo', 'bar', 'foo', 'foobar']) 13 | * // -> ['foo'] 14 | * 15 | * const input = [{ id: 1, a: 1 }, { id: 1, a: 2 }, { id: 2, a: 3 }, { id: 1, a: 4 }] 16 | * flocky.duplicates(input, (element) => element.id) 17 | * // -> [{ id: 1, a: 2 }, { id: 1, a: 4 }] 18 | * ``` 19 | */ 20 | 21 | export function duplicates(array: Array, identity?: (x: T) => unknown): Array { 22 | if (!identity) { 23 | return primitiveDuplicates(array) 24 | } 25 | 26 | return objectDuplicates(array, identity) 27 | } 28 | 29 | function primitiveDuplicates(array: Array): Array { 30 | return array.filter((x, i, self) => self.indexOf(x) !== i) 31 | } 32 | 33 | function objectDuplicates(array: Array, identity: (x: T) => unknown): Array { 34 | const identities = array.map((x) => identity(x)) 35 | return array.filter((_, i) => identities.indexOf(identities[i]) !== i) 36 | } 37 | -------------------------------------------------------------------------------- /src/escapeRegExp/escapeRegExp.spec.ts: -------------------------------------------------------------------------------- 1 | import { escapeRegExp } from './escapeRegExp' 2 | 3 | describe('escapeRegExp', () => { 4 | test('can escape the special characters in a string', () => { 5 | expect(escapeRegExp('How much $$$ for this?')).toEqual('How much \\$\\$\\$ for this\\?') 6 | expect(escapeRegExp('\\')).toEqual('\\\\') 7 | expect(escapeRegExp('^^')).toEqual('\\^\\^') 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /src/escapeRegExp/escapeRegExp.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### escapeRegExp(string) 3 | * 4 | * Escape special characters in a string for use in a regular expression. 5 | * 6 | * ```js 7 | * flocky.escapeRegExp('Hey. (1 + 1 = 2)') 8 | * // -> 'Hey\\. \\(1 \\+ 1 = 2\\)' 9 | * ``` 10 | */ 11 | 12 | export function escapeRegExp(string: string): string { 13 | return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 14 | } 15 | -------------------------------------------------------------------------------- /src/flatten/flatten.spec.ts: -------------------------------------------------------------------------------- 1 | import { flatten } from './flatten' 2 | 3 | describe('flatten', () => { 4 | test('flattens the object', () => { 5 | expect(flatten({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }) 6 | 7 | expect(flatten({ a: { b: 1 } })).toEqual({ 'a.b': 1 }) 8 | expect(flatten({ a: { b: 1, c: 2 } })).toEqual({ 'a.b': 1, 'a.c': 2 }) 9 | expect(flatten({ a: { b: 1, c: 2 }, d: { e: 3 } })).toEqual({ 'a.b': 1, 'a.c': 2, 'd.e': 3 }) 10 | 11 | expect(flatten({ a: { b: { c: 1 } } })).toEqual({ 'a.b.c': 1 }) 12 | expect(flatten({ a: { b: { c: 1, d: 2 } } })).toEqual({ 'a.b.c': 1, 'a.b.d': 2 }) 13 | expect(flatten({ a: { b: { c: 1, d: 2 } }, e: { f: { g: 3 } } })).toEqual({ 14 | 'a.b.c': 1, 15 | 'a.b.d': 2, 16 | 'e.f.g': 3, 17 | }) 18 | 19 | // Check that the types are correct 20 | const result = flatten({ a: { b: { c: 1, d: 2 } }, e: { f: { g: 3 } } }) 21 | expect(result['a.b.c'] + 1).toEqual(2) 22 | expect(result['a.b.d'] + 1).toEqual(3) 23 | expect(result['e.f.g'] + 1).toEqual(4) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/flatten/flatten.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### flatten(object) 3 | * 4 | * Flattens a nested object into an object with dot notation keys. 5 | * 6 | * ```js 7 | * flocky.flatten({ a: { b: 1, c: 2 }, d: { e: { f: 3 } } }) 8 | * // -> { 'a.b': 1, 'a.c': 2, 'd.e.f': 3 } 9 | * ``` 10 | */ 11 | 12 | type Entry = { key: string; value: unknown; optional: boolean } 13 | 14 | type Explode = _Explode ? { '0': T[number] } : T> 15 | 16 | type _Explode = T extends object 17 | ? { 18 | [K in keyof T]-?: K extends string 19 | ? Explode extends infer E 20 | ? E extends Entry 21 | ? { 22 | key: `${K}${E['key'] extends '' ? '' : '.'}${E['key']}` 23 | value: E['value'] 24 | optional: E['key'] extends '' 25 | ? object extends Pick 26 | ? true 27 | : false 28 | : E['optional'] 29 | } 30 | : never 31 | : never 32 | : never 33 | }[keyof T] 34 | : { key: ''; value: T; optional: false } 35 | 36 | type Collapse = { 37 | [E in Extract as E['key']]: E['value'] 38 | } & Partial<{ [E in Extract as E['key']]: E['value'] }> extends infer O 39 | ? { [K in keyof O]: O[K] } 40 | : never 41 | 42 | type FlattenObject = Collapse> 43 | 44 | export function flatten>( 45 | object: TObject 46 | ): FlattenObject { 47 | const result: Record = {} 48 | 49 | function recurse(current: Record, prefix = ''): void { 50 | for (const key in current) { 51 | const value = current[key] 52 | const nextKey = prefix ? `${prefix}.${key}` : key 53 | 54 | if (typeof value === 'object' && value !== null) { 55 | recurse(value as Record, nextKey) 56 | } else { 57 | result[nextKey] = value 58 | } 59 | } 60 | } 61 | 62 | recurse(object) 63 | return result as FlattenObject 64 | } 65 | -------------------------------------------------------------------------------- /src/generateDocs.ts: -------------------------------------------------------------------------------- 1 | import { filesize as fileSize } from 'filesize' 2 | import fs from 'fs' 3 | import pako from 'pako' 4 | import path from 'path' 5 | import * as terser from 'terser' 6 | import { parseModules } from './parseModules' 7 | 8 | const START_TOKEN = '' 9 | const END_TOKEN = '' 10 | 11 | run().catch((err) => console.error(err)) 12 | 13 | async function run(): Promise { 14 | // Parse the module documentations 15 | let modules = parseModules().sort((a, b) => a.name.localeCompare(b.name)) 16 | 17 | // Generate the subtext for each module (source link, benchmark link, module size) 18 | modules = await Promise.all( 19 | modules.map(async (module) => { 20 | const subText: Array = [] 21 | 22 | // Link to the source 23 | subText.push(`[Source](./src/${module.name}/${module.name}.ts)`) 24 | 25 | // Link to the benchmark 26 | const benchmarkPath = path.join(__dirname, `./${module.name}/BENCHMARK.md`) 27 | const hasBenchmark = fs.existsSync(benchmarkPath) 28 | if (hasBenchmark) { 29 | subText.push(`[Benchmark](./src/${module.name}/BENCHMARK.md)`) 30 | } 31 | 32 | // Calculate the module 33 | const sizes = await calculateModuleSizes(module.name) 34 | subText.push(`Minify: ${sizes.minSize}`) 35 | subText.push(`Minify & GZIP: ${sizes.minZipSize}`) 36 | 37 | // Append to the docs for this module 38 | module.docs += `\n${subText.join(' • ')}\n` 39 | return module 40 | }) 41 | ) 42 | 43 | // Replace the documentation for the individual modules in the readme 44 | let README = fs.readFileSync('./README.md', 'utf-8') 45 | README = README.replace( 46 | new RegExp(`${START_TOKEN}.*${END_TOKEN}`, 's'), 47 | [START_TOKEN, modules.map((x) => x.docs).join('\n'), END_TOKEN].join('\n') 48 | ) 49 | 50 | fs.writeFileSync('./README.md', README, 'utf-8') 51 | } 52 | 53 | async function calculateModuleSizes( 54 | name: string 55 | ): Promise<{ size: string; minSize: string; minZipSize: string }> { 56 | const content = fs.readFileSync(path.join(__dirname, `../dist/esm/${name}/${name}.js`), 'utf-8') 57 | 58 | const contentMin = (await terser.minify(content)).code || '' 59 | const contentMinZip = pako.deflate(contentMin) 60 | 61 | return { 62 | size: fileSize(content.length), 63 | minSize: fileSize(contentMin.length), 64 | minZipSize: fileSize(contentMinZip.length), 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/get/BENCHMARK.md: -------------------------------------------------------------------------------- 1 | ### Benchmark for `get` 2 | 3 | [Source for this benchmark](./get.benchmark.ts) 4 | 5 | | | lodash | flocky | 6 | | ----------- | -------------------------- | ------------------------------- | 7 | | array path | 1,090,098 ops/sec (94.41%) | **1,154,597 ops/sec (100.00%)** | 8 | | string path | 384,031 ops/sec (42.10%) | **912,167 ops/sec (100.00%)** | 9 | 10 | Generated at 2024-07-20 with Node.JS v20.9.0 11 | -------------------------------------------------------------------------------- /src/get/get.benchmark.ts: -------------------------------------------------------------------------------- 1 | import lodash from 'lodash' 2 | import { Benchmark } from '../benchmarkHelper' 3 | import { get } from './get' 4 | 5 | const OBJECT = { foo: { bar: { herp: 123 } } } 6 | 7 | const benchmark = new Benchmark('get') 8 | 9 | benchmark.add({ 10 | library: 'lodash', 11 | input: 'array path', 12 | func: (): unknown => lodash.get(OBJECT, ['foo', 'bar', 'herp' + Math.random()]), 13 | }) 14 | 15 | benchmark.add({ 16 | library: 'lodash', 17 | input: 'string path', 18 | func: (): unknown => lodash.get(OBJECT, 'foo.bar.herp' + Math.random()), 19 | }) 20 | 21 | benchmark.add({ 22 | library: 'flocky', 23 | input: 'array path', 24 | func: () => get(OBJECT, ['foo', 'bar', 'herp' + Math.random()]), 25 | }) 26 | 27 | benchmark.add({ 28 | library: 'flocky', 29 | input: 'string path', 30 | func: () => get(OBJECT, 'foo.bar.herp' + Math.random()), 31 | }) 32 | 33 | benchmark.run() 34 | -------------------------------------------------------------------------------- /src/get/get.spec.ts: -------------------------------------------------------------------------------- 1 | import { get } from './get' 2 | 3 | describe('get', () => { 4 | test('should work with the path of an object', () => { 5 | const object = { foo: { bar: { herp: 123, derp: 0 } } } 6 | expect(get(object, 'foo.bar.herp')).toEqual(123) 7 | expect(get(object, ['foo', 'bar', 'herp'])).toEqual(123) 8 | expect(get(object, 'foo.bar')).toEqual({ herp: 123, derp: 0 }) 9 | expect(get(object, 'foo[bar]')).toEqual({ herp: 123, derp: 0 }) 10 | expect(get(object, 'foo[bar[herp]]')).toEqual(123) 11 | expect(get(object, 'foo.bar.derp')).toEqual(0) 12 | 13 | // Does not mutate input 14 | expect(object).toEqual({ foo: { bar: { herp: 123, derp: 0 } } }) 15 | }) 16 | 17 | test('should return `undefined` if the path of an object does not exist', () => { 18 | const object = { foo: { bar: { herp: 123 } } } 19 | expect(get(object, 'foo.sup.flerp')).toEqual(undefined) 20 | expect(get(object, 'foo.sup.flerp.derp.merp')).toEqual(undefined) 21 | 22 | // Does not mutate input 23 | expect(object).toEqual({ foo: { bar: { herp: 123 } } }) 24 | }) 25 | 26 | test('should return `undefined` if part of an path of an object is undefined', () => { 27 | const object = { foo: null } 28 | expect(get(object, 'foo')).toEqual(null) 29 | expect(get(object, 'foo.bar')).toEqual(undefined) 30 | expect(get(object, 'bar')).toEqual(undefined) 31 | 32 | // Does not mutate input 33 | expect(object).toEqual({ foo: null }) 34 | 35 | const object2 = { foo: false } 36 | expect(get(object2, 'foo')).toEqual(false) 37 | expect(get(object2, 'foo.bar')).toEqual(undefined) 38 | expect(get(object2, 'bar')).toEqual(undefined) 39 | 40 | // Does not mutate input 41 | expect(object2).toEqual({ foo: false }) 42 | }) 43 | 44 | test('should return `undefined` if part of an path of an object is not an object', () => { 45 | const object = { foo: 'herp?' } 46 | expect(get(object, 'foo')).toEqual('herp?') 47 | expect(get(object, 'foo.bar')).toEqual(undefined) 48 | 49 | // Does not mutate input 50 | expect(object).toEqual({ foo: 'herp?' }) 51 | }) 52 | 53 | test('should return the default if the path of an object does not exist', () => { 54 | const object = { foo: { bar: { herp: 123 } } } 55 | expect(get(object, 'foo.sup.flerp', 'the default')).toEqual('the default') 56 | 57 | const object2 = { foo: { bar: { herp: null } } } 58 | expect(get(object2, 'foo.bar.herp.derp', 'default')).toEqual('default') 59 | expect(get(object2, 'foo.bar.herp', null)).toEqual(null) 60 | 61 | // Does not mutate input 62 | expect(object).toEqual({ foo: { bar: { herp: 123 } } }) 63 | }) 64 | 65 | test('should work with the path of array elements', () => { 66 | const object = { foo: { bar: ['hi', { herp: 123 }] } } 67 | expect(get(object, 'foo.bar[1].herp')).toEqual(123) 68 | expect(get(object, 'foo.bar.[1].herp')).toEqual(123) 69 | expect(get(object, ['foo', 'bar', '1', 'herp'])).toEqual(123) 70 | expect(get(object, ['foo', 'bar', 1, 'herp'])).toEqual(123) 71 | expect(get(object, 'foo.bar.1.herp')).toEqual(123) 72 | 73 | // Does not mutate input 74 | expect(object).toEqual({ foo: { bar: ['hi', { herp: 123 }] } }) 75 | }) 76 | 77 | test('should work at the start of a path of array elements', () => { 78 | const object = [{ foo: { bar: ['hi', { herp: 123 }] } }] 79 | expect(get(object, '[0].foo.bar[1].herp')).toEqual(123) 80 | expect(get(object, '[0].foo.bar.1.herp')).toEqual(123) 81 | expect(get(object, '0.foo.bar[1].herp')).toEqual(123) 82 | expect(get(object, '0.foo.bar.1.herp')).toEqual(123) 83 | 84 | // Does not mutate input 85 | expect(object).toEqual([{ foo: { bar: ['hi', { herp: 123 }] } }]) 86 | }) 87 | 88 | test('should return `undefined` if the path of array elements does not exist', () => { 89 | const object = { foo: { bar: [{ herp: 123 }] } } 90 | expect(get(object, 'foo.bar[1].herp')).toEqual(undefined) 91 | expect(get(object, 'foo.bar.1.herp')).toEqual(undefined) 92 | expect(get(object, 'foo.sup[0].herp')).toEqual(undefined) 93 | expect(get(object, 'foo.sup.0.herp')).toEqual(undefined) 94 | expect(get(object, 'foo.bar[0].flerp')).toEqual(undefined) 95 | expect(get(object, 'foo.bar.0.flerp')).toEqual(undefined) 96 | 97 | // Does not mutate input 98 | expect(object).toEqual({ foo: { bar: [{ herp: 123 }] } }) 99 | }) 100 | 101 | test('should return the default if the path of array elements does not exist', () => { 102 | const object = { foo: { bar: [{ herp: 123 }] } } 103 | expect(get(object, 'foo.bar[1].herp', 'the default')).toEqual('the default') 104 | expect(get(object, 'foo.bar.1.herp', 'the default')).toEqual('the default') 105 | expect(get(object, 'foo.sup[0].herp', 'the default')).toEqual('the default') 106 | expect(get(object, 'foo.sup.0.herp', 'the default')).toEqual('the default') 107 | expect(get(object, 'foo.bar[0].flerp', 'default')).toEqual('default') 108 | expect(get(object, 'foo.bar.0.flerp', 'the default')).toEqual('the default') 109 | expect(object).toEqual({ foo: { bar: [{ herp: 123 }] } }) 110 | }) 111 | 112 | test('should return `undefined` if object does not exist', () => { 113 | expect(get(null, 'foo.bar')).toEqual(undefined) 114 | expect(get(undefined, 'foo.bar')).toEqual(undefined) 115 | }) 116 | 117 | test('should return `undefined` if object is not an object', () => { 118 | // @ts-expect-error Ignore the type check for the first argument and test this, 119 | // because this misuse can happen very easily in JavaScript land 120 | expect(get('wat', 'foo.bar')).toEqual(undefined) 121 | }) 122 | 123 | test('should return `undefined` if the path is malformed', () => { 124 | const object = { foo: { bar: [{ herp: 123 }] } } 125 | expect(get(object, 'foo..')).toEqual(undefined) 126 | expect(get(object, 'foo.[].bar')).toEqual(undefined) 127 | expect(get(object, 'foo..bar')).toEqual(undefined) 128 | expect(get(object, '.foo.bar')).toEqual(undefined) 129 | expect(get(object, 'foo......[2][1][0].bar')).toEqual(undefined) 130 | expect(get(object, 'foo......[[1][0].bar')).toEqual(undefined) 131 | expect(get(object, 'foo......[[1][0].bar')).toEqual(undefined) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /src/get/get.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### get(object, path, defaultValue?) 3 | * 4 | * Get the value at a `path` of an `object` (with an optional `defaultValue`) 5 | * 6 | * :warning: **Using this method will ignore type information, and you will have 7 | * to type the return type yourself. If you can, it is always better to access 8 | * properties directly, for example with the "optional chaining" operator.** 9 | * 10 | * ```js 11 | * const object = {a: {b: {c: 1}}} 12 | * flocky.get(object, 'a.b.c') 13 | * // -> 1 14 | * 15 | * const object = {a: {b: {c: 1}}} 16 | * flocky.get(object, 'x.x.x') 17 | * // -> undefined 18 | * 19 | * const object = {a: {b: {c: 1}}} 20 | * flocky.get(object, 'x.x.x', 'default') 21 | * // -> 'default' 22 | * ``` 23 | */ 24 | 25 | const REPLACE_EMPTY_REGEX = /]|^\[/g 26 | const REPLACE_DOT_REGEX = /\.?\[/g 27 | 28 | export function get( 29 | object: object | null | undefined, 30 | path: string | Array, 31 | defaultValue?: unknown 32 | ): unknown { 33 | // Handle the case that the object is undefined or not an object 34 | if (!object || Object(object) !== object) { 35 | return defaultValue 36 | } 37 | 38 | // A) If the path is an array, we can just use that 39 | // B) If the path is a string, convert it into an array by migrating 40 | // array-style `[foo]` accessors into object-style `.foo` accessors 41 | const arrayPath = Array.isArray(path) ? path : parsePath(path) 42 | const result = getWithArrayPath(object, arrayPath) 43 | return result !== undefined ? result : defaultValue 44 | } 45 | 46 | function parsePath(path: string): Array { 47 | return path.replace(REPLACE_EMPTY_REGEX, '').replace(REPLACE_DOT_REGEX, '.').split('.') 48 | } 49 | 50 | function getWithArrayPath(object: object, path: Array): unknown { 51 | const length = path.length 52 | let index = 0 53 | let current = object as Record | null 54 | 55 | while (current != null && index < length) { 56 | current = current[path[index++]] as Record | null 57 | } 58 | 59 | return index === length ? current : undefined 60 | } 61 | -------------------------------------------------------------------------------- /src/hash/BENCHMARK.md: -------------------------------------------------------------------------------- 1 | ### Benchmark for `hash` 2 | 3 | [Source for this benchmark](./hash.benchmark.ts) 4 | 5 | | | flocky | 6 | | ---------- | ----------------------------- | 7 | | 1KB String | **160,315 ops/sec (100.00%)** | 8 | | 1MB String | **172 ops/sec (100.00%)** | 9 | 10 | Generated at 2024-07-20 with Node.JS v20.9.0 11 | -------------------------------------------------------------------------------- /src/hash/hash.benchmark.ts: -------------------------------------------------------------------------------- 1 | import { Benchmark } from '../benchmarkHelper' 2 | import { hash } from './hash' 3 | 4 | function makeString(length: number): string { 5 | let text = '' 6 | for (let i = 0; i < length; i++) { 7 | text += 'A' 8 | } 9 | return text 10 | } 11 | 12 | const ONE_KB_STRING = makeString(1024) 13 | const ONE_MB_STRING = makeString(1024 * 1024) 14 | 15 | const benchmark = new Benchmark('hash') 16 | 17 | benchmark.add({ 18 | library: 'flocky', 19 | input: '1KB String', 20 | func: () => hash(ONE_KB_STRING), 21 | }) 22 | 23 | benchmark.add({ 24 | library: 'flocky', 25 | input: '1MB String', 26 | func: () => hash(ONE_MB_STRING), 27 | }) 28 | 29 | benchmark.run() 30 | -------------------------------------------------------------------------------- /src/hash/hash.spec.ts: -------------------------------------------------------------------------------- 1 | import { hash } from './hash' 2 | 3 | describe('hash', () => { 4 | test('consistently hashes the input', () => { 5 | expect(hash('Never gonna give you up')).toEqual('x1bvo9jc') 6 | expect(hash('Never gonna give you up')).toEqual('x1bvo9jc') 7 | expect(hash('Never gonna give you up')).toEqual('x1bvo9jc') 8 | }) 9 | 10 | test('can hash any input type', () => { 11 | expect(hash('string')).toEqual('xcmbrgs') 12 | expect(hash(7)).toEqual('x1fq1y7a') 13 | expect(hash(true)).toEqual('x1rowxc0') 14 | 15 | expect(hash({ object: true })).toEqual('x1qx0xkp') 16 | expect(hash(['array'])).toEqual('xr7mzub') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/hash/hash.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from '../typeHelpers' 2 | 3 | /** 4 | * ### hash(data) 5 | * 6 | * Create a hashed string representation of the passed in data. 7 | * 8 | * :warning: **This function is not cryptographically secure, use 9 | * [`Argon2id`, `scrypt` or `bcrypt`](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#password-hashing-algorithms) 10 | * for anything security related.** 11 | * 12 | * ```js 13 | * flocky.hash('some really long string') 14 | * // -> 'x1nr7uiv' 15 | * 16 | * flocky.hash({id: 'AAA', name: 'BBB'}) 17 | * // -> 'x16mynva' 18 | * ``` 19 | * 20 | *
21 | * Implementation Details 22 | * 23 | * This method uses Murmur3 because it is small, fast and has fairly good 24 | * collision characteristics (about 1 in 36000). 25 | * 26 | * - https://softwareengineering.stackexchange.com/questions/49550 27 | * - https://github.com/VowpalWabbit/vowpal_wabbit/wiki/murmur2-vs-murmur3 28 | * - https://en.wikipedia.org/wiki/MurmurHash 29 | * - https://github.com/whitequark/murmurhash3-js/blob/master/murmurhash3.js 30 | *
31 | */ 32 | 33 | export function hash(data: JSONValue): string { 34 | // Convert any data into a string 35 | data = JSON.stringify(data) 36 | 37 | // Setup length, seed and chunk looping 38 | const len = data.length 39 | let hash = len ^ len 40 | const roundedEnd = len & ~0x1 41 | 42 | // Go through 4-byte chunks 43 | for (let i = 0; i < roundedEnd; i += 2) { 44 | let chunk = data.charCodeAt(i) | (data.charCodeAt(i + 1) << 16) 45 | 46 | chunk = mul32(chunk, 0xcc9e2d51) 47 | chunk = ((chunk & 0x1ffff) << 15) | (chunk >>> 17) 48 | chunk = mul32(chunk, 0x1b873593) 49 | 50 | hash ^= chunk 51 | hash = ((hash & 0x7ffff) << 13) | (hash >>> 19) 52 | hash = (hash * 5 + 0xe6546b64) | 0 53 | } 54 | 55 | // Handle remaining bytes 56 | if (len % 2 === 1) { 57 | let remaining = data.charCodeAt(roundedEnd) 58 | 59 | remaining = mul32(remaining, 0xcc9e2d51) 60 | remaining = ((remaining & 0x1ffff) << 15) | (remaining >>> 17) 61 | remaining = mul32(remaining, 0x1b873593) 62 | 63 | hash ^= remaining 64 | } 65 | 66 | // Finalize 67 | hash ^= len << 1 68 | hash ^= hash >>> 16 69 | hash = mul32(hash, 0x85ebca6b) 70 | hash ^= hash >>> 13 71 | hash = mul32(hash, 0xc2b2ae35) 72 | hash ^= hash >>> 16 73 | 74 | // Convert to string, ensuring start with character 75 | return 'x' + (hash >>> 0).toString(36) 76 | } 77 | 78 | // Multiply two 32-bit numbers 79 | function mul32(m: number, n: number): number { 80 | const nLow = n & 0xffff 81 | const nHigh = n - nLow 82 | 83 | return (((nHigh * m) | 0) + ((nLow * m) | 0)) | 0 84 | } 85 | -------------------------------------------------------------------------------- /src/identifier/identifier.spec.ts: -------------------------------------------------------------------------------- 1 | import { dateNow, mathRandom } from '../testHelpers' 2 | import { identifier } from './identifier' 3 | 4 | const UUID_FORMAT = /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i 5 | 6 | describe('identifier', () => { 7 | beforeEach(() => { 8 | mathRandom.setup() 9 | dateNow.setup() 10 | }) 11 | 12 | afterEach(() => { 13 | mathRandom.reset() 14 | dateNow.reset() 15 | }) 16 | 17 | test('generates a random identifier', () => { 18 | expect(identifier()).toEqual('bfc8d57e-b9ab-4245-836e-d1fd99602e30') 19 | }) 20 | }) 21 | 22 | describe('identifier (fuzzing)', () => { 23 | const ITERATIONS = 1000 24 | 25 | test('generates only valid UUIDs', () => { 26 | for (let i = 0; i !== ITERATIONS; i++) { 27 | const id = identifier() 28 | expect(id.match(UUID_FORMAT)).toBeTruthy() 29 | } 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/identifier/identifier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### identifier() 3 | * 4 | * Generate a random identifier with UUID v4 format. 5 | * 6 | * ```js 7 | * flocky.identifier() 8 | * // -> 'bfc8d57e-b9ab-4245-836e-d1fd99602e30' 9 | * ``` 10 | */ 11 | 12 | export function identifier(): string { 13 | let seed = Date.now() 14 | let uuid = '' 15 | 16 | for (let i = 0; i !== 36; i++) { 17 | // These spots have to be dashes 18 | // xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 19 | // ^ ^ ^ ^ 20 | if (i === 8 || i === 13 || i === 18 || i === 23) { 21 | uuid += '-' 22 | continue 23 | } 24 | 25 | // This spot has to be a "4" 26 | // xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 27 | // ^ 28 | if (i === 14) { 29 | uuid += '4' 30 | continue 31 | } 32 | 33 | // Generate a random byte and adjust the seed for the next random byte 34 | const randomByte = (seed + Math.random() * 16) % 16 | 0 35 | seed = Math.floor(seed / 16) 36 | 37 | // This spot has to be either "8", "9", "a" or "b". The bit-shifting 38 | // magic below achieves exactly that. 39 | // xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 40 | // ^ 41 | if (i === 19) { 42 | uuid += ((randomByte & 0x3) | 0x8).toString(16) 43 | continue 44 | } 45 | 46 | // These spots can just be random bytes (0-9, A-F) 47 | // xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 48 | // ^^^^^^^^ ^^^^ ^^^ ^^^ ^^^^^^^^^^^^ 49 | uuid += randomByte.toString(16) 50 | } 51 | 52 | return uuid 53 | } 54 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import * as _flocky from './index' 2 | import { parseModules } from './parseModules' 3 | import { dateNow, mathRandom } from './testHelpers' 4 | 5 | // This is here because the "import" is not a global variable for "eval" 6 | const flocky = _flocky 7 | 8 | describe('index file', () => { 9 | test('exports the modules', () => { 10 | expect(Object.keys(flocky).length).toBeGreaterThanOrEqual(5) 11 | }) 12 | 13 | test('exports custom errors', () => { 14 | expect(new _flocky.PromiseTimeoutError()).toBeInstanceOf(Error) 15 | }) 16 | }) 17 | 18 | describe('documentation examples', () => { 19 | beforeEach(() => { 20 | mathRandom.setup() 21 | dateNow.setup() 22 | }) 23 | 24 | afterEach(() => { 25 | mathRandom.reset() 26 | dateNow.reset() 27 | }) 28 | 29 | const modules = parseModules() 30 | modules.forEach((module) => { 31 | const { name, examples } = module 32 | 33 | describe(name, () => { 34 | examples.forEach((example, index) => { 35 | test(`Example #${index + 1}`, async () => { 36 | const actual = await eval(example.code) 37 | expect(actual).toEqual(example.expected) 38 | }) 39 | }) 40 | }) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { average } from './average/average' 2 | export { chunk } from './chunk/chunk' 3 | export { clamp } from './clamp/clamp' 4 | export { clone } from './clone/clone' 5 | export { compact } from './compact/compact' 6 | export { debounce } from './debounce/debounce' 7 | export { duplicates } from './duplicates/duplicates' 8 | export { escapeRegExp } from './escapeRegExp/escapeRegExp' 9 | export { flatten } from './flatten/flatten' 10 | export { get } from './get/get' 11 | export { hash } from './hash/hash' 12 | export { identifier } from './identifier/identifier' 13 | export { matchAll } from './matchAll/matchAll' 14 | export { max } from './max/max' 15 | export { memoize } from './memoize/memoize' 16 | export { min } from './min/min' 17 | export { omit } from './omit/omit' 18 | export { percentile } from './percentile/percentile' 19 | export { pick } from './pick/pick' 20 | export { promisePool } from './promisePool/promisePool' 21 | export { PromiseTimeoutError, promiseTimeout } from './promiseTimeout/promiseTimeout' 22 | export { random } from './random/random' 23 | export { randomString } from './randomString/randomString' 24 | export { range } from './range/range' 25 | export { roundTo } from './roundTo/roundTo' 26 | export { sample } from './sample/sample' 27 | export { shuffle } from './shuffle/shuffle' 28 | export { sleep } from './sleep/sleep' 29 | export { slugify } from './slugify/slugify' 30 | export { sum } from './sum/sum' 31 | export { throttle } from './throttle/throttle' 32 | export { toMap } from './toMap/toMap' 33 | export { unflatten } from './unflatten/unflatten' 34 | export { unique } from './unique/unique' 35 | -------------------------------------------------------------------------------- /src/matchAll/matchAll.spec.ts: -------------------------------------------------------------------------------- 1 | import { matchAll } from './matchAll' 2 | 3 | describe('matchAll', () => { 4 | test('matches all occurrences of the regular expression', () => { 5 | expect(matchAll(/\d+/g, '$200 and $400')).toEqual([ 6 | { match: '200', subMatches: [], index: 1 }, 7 | { match: '400', subMatches: [], index: 10 }, 8 | ]) 9 | }) 10 | 11 | test('matches all occurrences of the regular expression with submatches', () => { 12 | expect(matchAll(/(\d+)/g, '$200 and $400')).toEqual([ 13 | { match: '200', subMatches: ['200'], index: 1 }, 14 | { match: '400', subMatches: ['400'], index: 10 }, 15 | ]) 16 | }) 17 | 18 | test('matches all occurrences of the regular expression with multiple submatches', () => { 19 | expect(matchAll(/(\d+) and (\d+)/g, '200 and 400 or also 300 and 500')).toEqual([ 20 | { match: '200 and 400', subMatches: ['200', '400'], index: 0 }, 21 | { match: '300 and 500', subMatches: ['300', '500'], index: 20 }, 22 | ]) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/matchAll/matchAll.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### matchAll(regExp, string) 3 | * 4 | * Find all matches of a regular expression in a string. 5 | * 6 | * ```js 7 | * flocky.matchAll(/f(o+)/g, 'foo bar baz foooo bar') 8 | * // -> [ 9 | * // -> { match: 'foo', subMatches: ['oo'], index: 0 }, 10 | * // -> { match: 'foooo', subMatches: ['oooo'], index: 12 }, 11 | * // -> ] 12 | * ``` 13 | */ 14 | 15 | export interface MatchAllMatch { 16 | match: string 17 | subMatches: Array 18 | index: number 19 | } 20 | 21 | export function matchAll(regExp: RegExp, string: string): Array { 22 | return Array.from(string.matchAll(regExp)).map(formatMatch) 23 | } 24 | 25 | function formatMatch(match: RegExpMatchArray): MatchAllMatch { 26 | return { 27 | match: match[0], 28 | subMatches: match.slice(1, match.length), 29 | index: match.index!, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/max/max.spec.ts: -------------------------------------------------------------------------------- 1 | import { max } from './max' 2 | 3 | describe('max', () => { 4 | test('calculates the maximum of an array of numbers', () => { 5 | expect(max([4, 2, 6])).toEqual(6) 6 | expect(max([9, 4, 3, 7, 79, -60])).toEqual(79) 7 | expect(max([])).toEqual(-Infinity) 8 | }) 9 | 10 | test('does not mutate the input', () => { 11 | const input = [1, 2, 3] 12 | max(input) 13 | expect(input).toEqual([1, 2, 3]) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/max/max.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### max(array) 3 | * 4 | * Compute the maximum of the values in an array. 5 | * 6 | * ```js 7 | * flocky.max([1, 4, 2, -3, 0]) 8 | * // -> 4 9 | * ``` 10 | */ 11 | 12 | export function max(array: Array): number { 13 | return Math.max.apply(null, array) 14 | } 15 | -------------------------------------------------------------------------------- /src/memoize/BENCHMARK.md: -------------------------------------------------------------------------------- 1 | ### Benchmark for `memoize` 2 | 3 | [Source for this benchmark](./memoize.benchmark.ts) 4 | 5 | | | lodash | fast-memoize | flocky | 6 | | -------------------- | --------------------------- | --------------------------------- | ------------------------------- | 7 | | monadic (primitive) | 85,503,748 ops/sec (41.94%) | **203,874,391 ops/sec (100.00%)** | 198,755,703 ops/sec (97.49%) | 8 | | monadic (serialized) | 3,292,794 ops/sec (44.06%) | 2,607,224 ops/sec (34.89%) | **7,473,005 ops/sec (100.00%)** | 9 | | variadic | 3,222,679 ops/sec (79.33%) | 1,620,215 ops/sec (39.88%) | **4,062,624 ops/sec (100.00%)** | 10 | 11 | Generated at 2024-07-20 with Node.JS v20.9.0 12 | -------------------------------------------------------------------------------- /src/memoize/memoize.benchmark.ts: -------------------------------------------------------------------------------- 1 | import fastMemoize from 'fast-memoize' 2 | import lodash from 'lodash' 3 | import { Benchmark } from '../benchmarkHelper' 4 | import { memoize } from './memoize' 5 | 6 | function monadicPrimitiveFunc(a: number): number { 7 | return a + 1 8 | } 9 | 10 | function monadicSerializedFunc(a: string): string { 11 | return a + '1' 12 | } 13 | 14 | function variadicFunc(a: number, b: number): number { 15 | return a + b 16 | } 17 | 18 | const benchmark = new Benchmark('memoize') 19 | 20 | const lodashMonadicPrimitiveFunc = lodash.memoize(monadicPrimitiveFunc) 21 | benchmark.add({ 22 | library: 'lodash', 23 | input: 'monadic (primitive)', 24 | func: () => lodashMonadicPrimitiveFunc(1), 25 | }) 26 | 27 | const fastMemoizeMonadicPrimitiveFunc = fastMemoize(monadicPrimitiveFunc, { 28 | strategy: fastMemoize.strategies.monadic, 29 | }) 30 | benchmark.add({ 31 | library: 'fast-memoize', 32 | input: 'monadic (primitive)', 33 | func: () => fastMemoizeMonadicPrimitiveFunc(1), 34 | }) 35 | 36 | const flockyMonadicPrimitiveFunc = memoize(monadicPrimitiveFunc, { strategy: 'monadic' }) 37 | benchmark.add({ 38 | library: 'flocky', 39 | input: 'monadic (primitive)', 40 | func: () => flockyMonadicPrimitiveFunc(1), 41 | }) 42 | 43 | const lodashMonadicSerializedFunc = lodash.memoize(monadicSerializedFunc, (...args) => 44 | JSON.stringify(args) 45 | ) 46 | benchmark.add({ 47 | library: 'lodash', 48 | input: 'monadic (serialized)', 49 | func: () => lodashMonadicSerializedFunc('1'), 50 | }) 51 | 52 | const fastMemoizeMonadicSerializedFunc = fastMemoize(monadicSerializedFunc, { 53 | strategy: fastMemoize.strategies.monadic, 54 | }) 55 | benchmark.add({ 56 | library: 'fast-memoize', 57 | input: 'monadic (serialized)', 58 | func: () => fastMemoizeMonadicSerializedFunc('1'), 59 | }) 60 | 61 | const flockyMonadicSerializedFunc = memoize(monadicSerializedFunc, { strategy: 'monadic' }) 62 | benchmark.add({ 63 | library: 'flocky', 64 | input: 'monadic (serialized)', 65 | func: () => flockyMonadicSerializedFunc('1'), 66 | }) 67 | 68 | const lodashVariadicFunc = lodash.memoize(variadicFunc, (...args) => JSON.stringify(args)) 69 | benchmark.add({ 70 | library: 'lodash', 71 | input: 'variadic', 72 | func: () => lodashVariadicFunc(1, 2), 73 | }) 74 | 75 | const fastMemoizeVariadicFunc = fastMemoize(variadicFunc, { 76 | strategy: fastMemoize.strategies.variadic, 77 | }) 78 | benchmark.add({ 79 | library: 'fast-memoize', 80 | input: 'variadic', 81 | func: () => fastMemoizeVariadicFunc(1, 2), 82 | }) 83 | 84 | const flockyVariadicFunc = memoize(variadicFunc, { strategy: 'variadic' }) 85 | benchmark.add({ 86 | library: 'flocky', 87 | input: 'variadic', 88 | func: () => flockyVariadicFunc(1, 2), 89 | }) 90 | 91 | benchmark.run() 92 | -------------------------------------------------------------------------------- /src/memoize/memoize.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '../sleep/sleep' 2 | import { MemoizeOptions, memoize } from './memoize' 3 | 4 | type NumberObject = { n: number } 5 | 6 | describe('memoize', () => { 7 | test('memoizes function calls with no arguments', () => { 8 | const calls: Array<[]> = [] 9 | const func = (): number => { 10 | calls.push([]) 11 | return 1 12 | } 13 | 14 | const memoizedFunc = memoize(func) 15 | 16 | expect(memoizedFunc()).toEqual(1) 17 | expect(memoizedFunc()).toEqual(1) 18 | expect(calls).toEqual([[]]) 19 | }) 20 | 21 | test('memoizes function calls with a single primitive argument', () => { 22 | const calls: Array> = [] 23 | const func = (x: number): number => { 24 | calls.push([x]) 25 | return x + 1 26 | } 27 | 28 | const memoizedFunc = memoize(func) 29 | 30 | expect(memoizedFunc(1)).toEqual(2) 31 | expect(memoizedFunc(1)).toEqual(2) 32 | expect(memoizedFunc(2)).toEqual(3) 33 | expect(memoizedFunc(1)).toEqual(2) 34 | expect(calls).toEqual([[1], [2]]) 35 | }) 36 | 37 | test('memoizes function calls with a single non-primitive argument', () => { 38 | const calls: Array> = [] 39 | const func = (x: NumberObject): number => { 40 | calls.push([x]) 41 | return x.n + 1 42 | } 43 | 44 | const memoizedFunc = memoize(func) 45 | 46 | expect(memoizedFunc({ n: 1 })).toEqual(2) 47 | expect(memoizedFunc({ n: 1 })).toEqual(2) 48 | expect(memoizedFunc({ n: 2 })).toEqual(3) 49 | expect(memoizedFunc({ n: 1 })).toEqual(2) 50 | expect(calls).toEqual([[{ n: 1 }], [{ n: 2 }]]) 51 | }) 52 | 53 | test('memoizes function calls with multiple primitive arguments', () => { 54 | const calls: Array> = [] 55 | const func = (a: number, b: number): number => { 56 | calls.push([a, b]) 57 | return a + b 58 | } 59 | 60 | const memoizedFunc = memoize(func) 61 | 62 | expect(memoizedFunc(1, 2)).toEqual(3) 63 | expect(memoizedFunc(1, 2)).toEqual(3) 64 | expect(memoizedFunc(3, 4)).toEqual(7) 65 | expect(memoizedFunc(1, 2)).toEqual(3) 66 | expect(memoizedFunc(1, 4)).toEqual(5) 67 | expect(calls).toEqual([ 68 | [1, 2], 69 | [3, 4], 70 | [1, 4], 71 | ]) 72 | }) 73 | 74 | test('memoizes function calls with multiple non-primitive arguments', () => { 75 | const calls: Array> = [] 76 | const func = (a: NumberObject, b: NumberObject): number => { 77 | calls.push([a, b]) 78 | return a.n + b.n 79 | } 80 | 81 | const memoizedFunc = memoize(func) 82 | 83 | expect(memoizedFunc({ n: 1 }, { n: 2 })).toEqual(3) 84 | expect(memoizedFunc({ n: 1 }, { n: 2 })).toEqual(3) 85 | expect(memoizedFunc({ n: 3 }, { n: 4 })).toEqual(7) 86 | expect(memoizedFunc({ n: 1 }, { n: 2 })).toEqual(3) 87 | expect(memoizedFunc({ n: 1 }, { n: 4 })).toEqual(5) 88 | expect(calls).toEqual([ 89 | [{ n: 1 }, { n: 2 }], 90 | [{ n: 3 }, { n: 4 }], 91 | [{ n: 1 }, { n: 4 }], 92 | ]) 93 | }) 94 | 95 | test('memoizes function calls with spread primitive arguments', () => { 96 | const calls: Array> = [] 97 | const func = (multiplier: number, ...numbers: Array): Array => { 98 | calls.push([multiplier, ...numbers]) 99 | return numbers.map((x) => multiplier * x) 100 | } 101 | 102 | const memoizedFunc = memoize(func, { 103 | strategy: 'variadic', 104 | }) 105 | 106 | expect(memoizedFunc(2, 1, 2, 3)).toEqual([2, 4, 6]) 107 | expect(memoizedFunc(3, 4, 5, 6)).toEqual([12, 15, 18]) 108 | expect(memoizedFunc(2, 1, 2, 3)).toEqual([2, 4, 6]) 109 | expect(calls).toEqual([ 110 | [2, 1, 2, 3], 111 | [3, 4, 5, 6], 112 | ]) 113 | }) 114 | 115 | test('memoizes function calls with spread non-primitive arguments', () => { 116 | const calls: Array> = [] 117 | const func = (multiplier: NumberObject, ...numbers: Array): Array => { 118 | calls.push([multiplier, ...numbers]) 119 | return numbers.map((x) => multiplier.n * x.n) 120 | } 121 | 122 | const memoizedFunc = memoize(func, { 123 | strategy: 'variadic', 124 | }) 125 | 126 | expect(memoizedFunc({ n: 2 }, { n: 1 }, { n: 2 })).toEqual([2, 4]) 127 | expect(memoizedFunc({ n: 3 }, { n: 4 }, { n: 5 })).toEqual([12, 15]) 128 | expect(memoizedFunc({ n: 2 }, { n: 1 }, { n: 2 })).toEqual([2, 4]) 129 | expect(calls).toEqual([ 130 | [{ n: 2 }, { n: 1 }, { n: 2 }], 131 | [{ n: 3 }, { n: 4 }, { n: 5 }], 132 | ]) 133 | }) 134 | 135 | test('passes arguments as their original primitive', () => { 136 | const func = (x: unknown): string => 137 | typeof x === 'object' && x ? x.constructor.name : typeof x 138 | 139 | const memoizedFunc = memoize(func) 140 | 141 | expect(memoizedFunc(1)).toEqual('number') 142 | expect(memoizedFunc('2')).toEqual('string') 143 | expect(memoizedFunc({ n: 3 })).toEqual('Object') 144 | }) 145 | 146 | test('can define a custom strategy (monadic)', () => { 147 | const calls: Array> = [] 148 | const func = (a: number, b: number): number => { 149 | calls.push([a, b]) 150 | return a 151 | } 152 | 153 | const memoizedFunc = memoize(func, { 154 | strategy: 'monadic', 155 | }) 156 | 157 | expect(memoizedFunc(1, 2)).toEqual(1) 158 | expect(memoizedFunc(1, 2)).toEqual(1) 159 | expect(memoizedFunc(1, 3)).toEqual(1) 160 | expect(memoizedFunc(3, 4)).toEqual(3) 161 | expect(memoizedFunc(1, 2)).toEqual(1) 162 | expect(memoizedFunc(1, 4)).toEqual(1) 163 | expect(calls).toEqual([ 164 | [1, undefined], 165 | [3, undefined], 166 | ]) 167 | }) 168 | 169 | test('can define a custom strategy (variadic)', () => { 170 | const calls: Array> = [] 171 | const func = (x: number): number => { 172 | calls.push([x]) 173 | return x + 1 174 | } 175 | 176 | const memoizedFunc = memoize(func, { 177 | strategy: 'variadic', 178 | }) 179 | 180 | expect(memoizedFunc(1)).toEqual(2) 181 | expect(memoizedFunc(1)).toEqual(2) 182 | expect(memoizedFunc(2)).toEqual(3) 183 | expect(memoizedFunc(1)).toEqual(2) 184 | expect(calls).toEqual([[1], [2]]) 185 | }) 186 | 187 | test('can define a custom serializer', () => { 188 | let serializerCalls = 0 189 | function serializer(data: unknown): string { 190 | serializerCalls++ 191 | return '__' + JSON.stringify(data) + '__' 192 | } 193 | 194 | const func = (a: number, b: number): number => { 195 | return a + b 196 | } 197 | 198 | const memoizedFunc = memoize(func, { serializer }) 199 | 200 | expect(memoizedFunc(1, 2)).toEqual(3) 201 | expect(memoizedFunc(1, 2)).toEqual(3) 202 | expect(memoizedFunc(3, 4)).toEqual(7) 203 | expect(memoizedFunc(1, 2)).toEqual(3) 204 | expect(memoizedFunc(1, 4)).toEqual(5) 205 | expect(serializerCalls).toEqual(5) 206 | }) 207 | 208 | test('memoizes function calls that return promises', async () => { 209 | let calls = 0 210 | const func = async (): Promise => { 211 | calls++ 212 | await sleep(10) 213 | return 'A' 214 | } 215 | 216 | const memoizedFunc = memoize(func) 217 | 218 | const a = memoizedFunc() 219 | expect(a).toBeInstanceOf(Promise) 220 | const b = memoizedFunc() 221 | expect(b).toBeInstanceOf(Promise) 222 | expect(calls).toEqual(1) 223 | 224 | expect(await a).toEqual('A') 225 | expect(await b).toEqual('A') 226 | expect(calls).toEqual(1) 227 | 228 | const c = memoizedFunc() 229 | expect(await c).toEqual('A') 230 | expect(calls).toEqual(1) 231 | }) 232 | 233 | test('memoizes function calls with a maximum TTL', async () => { 234 | const calls: Array> = [] 235 | const func = (a: number, b: number): number => { 236 | calls.push([a, b]) 237 | return a + b 238 | } 239 | 240 | const memoizedFunc = memoize(func, { 241 | ttl: 10, 242 | }) 243 | 244 | expect(memoizedFunc(1, 2)).toEqual(3) 245 | expect(memoizedFunc(1, 2)).toEqual(3) 246 | expect(memoizedFunc(3, 4)).toEqual(7) 247 | expect(memoizedFunc(1, 2)).toEqual(3) 248 | expect(calls).toEqual([ 249 | [1, 2], 250 | [3, 4], 251 | ]) 252 | 253 | await sleep(20) 254 | expect(memoizedFunc(1, 2)).toEqual(3) 255 | expect(calls).toEqual([ 256 | [1, 2], 257 | [3, 4], 258 | [1, 2], 259 | ]) 260 | }) 261 | 262 | test.each([ 263 | ['monadic', { strategy: 'monadic' }], 264 | ['variadic', { strategy: 'variadic' }], 265 | ['ttl', { ttl: 50 }], 266 | ] as Array<[string, MemoizeOptions]>)( 267 | 'smartly memoizes function calls that return promise rejections (%s)', 268 | async (_: string, options: MemoizeOptions) => { 269 | let calls = 0 270 | const func = async (): Promise => { 271 | calls++ 272 | await sleep(10) 273 | 274 | if (calls === 1) { 275 | throw new Error('Uh oh') 276 | } 277 | 278 | return 'A' 279 | } 280 | 281 | const memoizedFunc = memoize(func, options) 282 | 283 | // We want to memoize the second call because the Promise is still pending 284 | const a = memoizedFunc() 285 | expect(a).toBeInstanceOf(Promise) 286 | const b = memoizedFunc() 287 | expect(b).toBeInstanceOf(Promise) 288 | expect(calls).toEqual(1) 289 | 290 | await expect(a).rejects.toThrow('Uh oh') 291 | await expect(b).rejects.toThrow('Uh oh') 292 | expect(calls).toEqual(1) 293 | 294 | // We don't want to memoize the third call because the Promise has rejected 295 | const c = memoizedFunc() 296 | expect(await c).toEqual('A') 297 | expect(calls).toEqual(2) 298 | } 299 | ) 300 | 301 | test('has the correct type', () => { 302 | const func = (a: number, b: number): number => { 303 | return a + b 304 | } 305 | 306 | const memoizedFunc = memoize(func) 307 | 308 | // @ts-expect-error The first argument has to be a number 309 | memoizedFunc('a', 2) 310 | 311 | // @ts-expect-error The second argument has to be a number 312 | memoizedFunc(2, 'a') 313 | 314 | // @ts-expect-error The return value is a number 315 | expect(() => memoizedFunc(2, 2).concat()).toThrow() 316 | 317 | // This one is okay 318 | memoizedFunc(2, 2).toFixed(2) 319 | }) 320 | }) 321 | -------------------------------------------------------------------------------- /src/memoize/memoize.ts: -------------------------------------------------------------------------------- 1 | import { TAnyFunction } from '../typeHelpers' 2 | 3 | export interface MemoizeOptions { 4 | strategy?: MemoizeStrategy 5 | serializer?: MemoizeSerializer 6 | ttl?: number 7 | } 8 | 9 | type MemoizeStrategy = 'monadic' | 'variadic' 10 | 11 | type MemoizeSerializer = (data: unknown) => string 12 | 13 | /** 14 | * ### memoize(func, options?) 15 | * 16 | * Create a function that memoizes the return value of `func`. 17 | * 18 | * ```js 19 | * const func = (a, b) => a + b 20 | * const memoizedFunc = flocky.memoize(func) 21 | * const memoizedFuncWithTtl = flocky.memoize(func, { ttl: 30 * 1000 }) 22 | * memoizedFunc(1, 2) 23 | * // -> 3 24 | * ``` 25 | * 26 | *
27 | * Implementation Details 28 | * 29 | * This method's implementation is based on [fast-memoize](https://github.com/caiogondim/fast-memoize.js), 30 | * with some improvements for variadic performance and additional support for a TTL based cache. 31 | *
32 | */ 33 | 34 | export function memoize>( 35 | this: TThis, 36 | func: TFunc, 37 | options: MemoizeOptions = {} 38 | ): TFunc { 39 | const strategy = 40 | options.strategy === 'monadic' || (options.strategy !== 'variadic' && func.length <= 1) 41 | ? monadic 42 | : variadic 43 | const cache = options.ttl ? ttlCache(options.ttl) : defaultCache() 44 | const serializer = options.serializer ? options.serializer : defaultSerializer 45 | 46 | return strategy.bind(this, func, cache, serializer) as TFunc 47 | } 48 | 49 | function isPrimitive(value: unknown): value is string { 50 | // We can not treat strings as primitive, because they overwrite numbers 51 | return value == null || typeof value === 'number' || typeof value === 'boolean' 52 | } 53 | 54 | function monadic>( 55 | this: TThis, 56 | func: TFunc, 57 | cache: MemoizeCache, 58 | serializer: MemoizeSerializer, 59 | arg: unknown 60 | ): TReturn { 61 | const cacheKey = isPrimitive(arg) ? arg : serializer(arg) 62 | 63 | let value = cache.get(cacheKey) 64 | if (typeof value === 'undefined') { 65 | value = func.call(this, arg) 66 | 67 | if (value instanceof Promise) { 68 | value.catch(() => cache.remove(cacheKey)) 69 | } 70 | 71 | cache.set(cacheKey, value) 72 | } 73 | 74 | return value 75 | } 76 | 77 | function variadic>( 78 | this: TThis, 79 | func: TFunc, 80 | cache: MemoizeCache, 81 | serializer: MemoizeSerializer, 82 | ...args: Array 83 | ): TReturn { 84 | const cacheKey = serializer(args) 85 | 86 | let value = cache.get(cacheKey) 87 | if (typeof value === 'undefined') { 88 | value = func.apply(this, args) 89 | 90 | if (value instanceof Promise) { 91 | value.catch(() => cache.remove(cacheKey)) 92 | } 93 | 94 | cache.set(cacheKey, value) 95 | } 96 | 97 | return value 98 | } 99 | 100 | function defaultSerializer(data: unknown): string { 101 | return JSON.stringify(data) 102 | } 103 | 104 | interface MemoizeCache { 105 | get: (key: string) => TReturn | undefined 106 | set: (key: string, value: TReturn) => void 107 | remove: (key: string) => void 108 | } 109 | 110 | function defaultCache(): MemoizeCache { 111 | const cache = Object.create(null) as Record 112 | 113 | return { 114 | get: (key) => cache[key], 115 | set: (key, value): void => { 116 | cache[key] = value 117 | }, 118 | remove: (key) => delete cache[key], 119 | } 120 | } 121 | 122 | function ttlCache(ttl: number): MemoizeCache { 123 | const cache = Object.create(null) as Record 124 | 125 | return { 126 | get: (key) => cache[key], 127 | set: (key, value): void => { 128 | cache[key] = value 129 | 130 | // Note: We do not need to clear the timeout because we never set a key 131 | // if it still exists in the cache. 132 | setTimeout(() => { 133 | delete cache[key] 134 | }, ttl) 135 | }, 136 | remove: (key) => delete cache[key], 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/min/min.spec.ts: -------------------------------------------------------------------------------- 1 | import { min } from './min' 2 | 3 | describe('min', () => { 4 | test('calculates the minimum of an array of numbers', () => { 5 | expect(min([4, 2, 6])).toEqual(2) 6 | expect(min([9, 4, 3, 7, 79, -60])).toEqual(-60) 7 | expect(min([])).toEqual(Infinity) 8 | }) 9 | 10 | test('does not mutate the input', () => { 11 | const input = [1, 2, 3] 12 | min(input) 13 | expect(input).toEqual([1, 2, 3]) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/min/min.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### min(array) 3 | * 4 | * Compute the minimum of the values in an array. 5 | * 6 | * ```js 7 | * flocky.min([1, 4, 2, -3, 0]) 8 | * // -> -3 9 | * ``` 10 | */ 11 | 12 | export function min(array: Array): number { 13 | return Math.min.apply(null, array) 14 | } 15 | -------------------------------------------------------------------------------- /src/omit/BENCHMARK.md: -------------------------------------------------------------------------------- 1 | ### Benchmark for `omit` 2 | 3 | [Source for this benchmark](./omit.benchmark.ts) 4 | 5 | | | flocky | 6 | | -------------------------- | ------------------------------- | 7 | | 5 properties / 3 omitted | **8,553,947 ops/sec (100.00%)** | 8 | | 26 properties / 3 omitted | **460,434 ops/sec (100.00%)** | 9 | | 10 properties / 55 omitted | **1,238,164 ops/sec (100.00%)** | 10 | 11 | Generated at 2024-07-20 with Node.JS v20.9.0 12 | -------------------------------------------------------------------------------- /src/omit/omit.benchmark.ts: -------------------------------------------------------------------------------- 1 | import { Benchmark } from '../benchmarkHelper' 2 | import { randomString } from '../randomString/randomString' 3 | import { omit } from './omit' 4 | 5 | const benchmark = new Benchmark('omit') 6 | 7 | const omitKeys: Array = [] 8 | for (let i = 0; i !== 55; i++) { 9 | omitKeys.push(randomString(10)) 10 | } 11 | 12 | benchmark.add({ 13 | library: 'flocky', 14 | input: '5 properties / 3 omitted', 15 | func: () => omit({ a: 1, b: 2, c: 3, d: 4, e: 5 }, ['a', 'b', 'd']), 16 | }) 17 | 18 | benchmark.add({ 19 | library: 'flocky', 20 | input: '26 properties / 3 omitted', 21 | func: () => { 22 | const object = { 23 | a: 1, 24 | b: 2, 25 | c: 3, 26 | d: 4, 27 | e: 5, 28 | f: 6, 29 | g: 7, 30 | h: 8, 31 | i: 9, 32 | j: 10, 33 | k: 11, 34 | l: 12, 35 | m: 13, 36 | n: 14, 37 | o: 15, 38 | p: 16, 39 | q: 17, 40 | r: 18, 41 | s: 19, 42 | t: 20, 43 | u: 21, 44 | v: 22, 45 | w: 23, 46 | x: 24, 47 | y: 25, 48 | z: 26, 49 | } 50 | 51 | return omit(object, ['a', 'b', 'd']) 52 | }, 53 | }) 54 | 55 | benchmark.add({ 56 | library: 'flocky', 57 | input: '10 properties / 55 omitted', 58 | func: () => { 59 | const object = { 60 | a: 1, 61 | b: 2, 62 | c: 3, 63 | d: 4, 64 | e: 5, 65 | f: 6, 66 | g: 7, 67 | h: 8, 68 | i: 9, 69 | j: 10, 70 | } 71 | 72 | return omit(object, omitKeys as Array) 73 | }, 74 | }) 75 | 76 | benchmark.run() 77 | -------------------------------------------------------------------------------- /src/omit/omit.spec.ts: -------------------------------------------------------------------------------- 1 | import { omit } from './omit' 2 | 3 | describe('omit', () => { 4 | test('omits the specified keys of the object', () => { 5 | const original = { a: 1, b: 2, c: 3, d: 4, e: 5 } 6 | const omitted = omit(original, ['a', 'b', 'd']) 7 | 8 | expect(original).toEqual({ a: 1, b: 2, c: 3, d: 4, e: 5 }) 9 | expect(omitted).toEqual({ c: 3, e: 5 }) 10 | }) 11 | 12 | test('has the correct type behavior', () => { 13 | interface StackProps { 14 | children: string 15 | gap: string 16 | } 17 | 18 | function omitFromProps(props: StackProps): Omit { 19 | return omit(props, ['gap']) 20 | } 21 | 22 | expect(omitFromProps({ children: 'foo', gap: 'bar' })).toEqual({ children: 'foo' }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/omit/omit.ts: -------------------------------------------------------------------------------- 1 | type Omit = Pick> 2 | 3 | /** 4 | * ### omit(object, keys) 5 | * 6 | * Create an object composed of all existing keys that are not specified in `keys`. 7 | * 8 | * ```js 9 | * const object = { a: 1, b: 2, c: 3 } 10 | * flocky.omit(object, ['a']) 11 | * // -> { b: 2, c: 3 } 12 | * ``` 13 | */ 14 | 15 | export function omit(object: T, keys: Array): Omit { 16 | const objectKeys = Object.keys(object) as Array 17 | 18 | const result: Partial = {} 19 | for (let i = 0; i !== objectKeys.length; i++) { 20 | const objectKey = objectKeys[i] as K 21 | 22 | if (keys.indexOf(objectKey) === -1) { 23 | result[objectKey] = object[objectKey] 24 | } 25 | } 26 | 27 | return result as Omit 28 | } 29 | -------------------------------------------------------------------------------- /src/parseModules.ts: -------------------------------------------------------------------------------- 1 | import glob from 'fast-glob' 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | const EXAMPLE_REGEX = /```js\n(.*?)\n```/s 6 | 7 | interface ModuleFile { 8 | filePath: string 9 | name: string 10 | docs: string 11 | examples: Array 12 | } 13 | 14 | interface Example { 15 | code: string 16 | expected: unknown 17 | } 18 | 19 | export function parseModules(): Array { 20 | return getModulePaths() 21 | .map(parseModule) 22 | .filter((x): x is ModuleFile => Boolean(x)) 23 | } 24 | 25 | function getModulePaths(): Array { 26 | const paths = glob.sync(path.join(__dirname, '../src/*/*.ts')) 27 | return paths.filter((path) => path.match(/src\/(.*?)\/\1.ts/)) 28 | } 29 | 30 | function parseModule(filePath: string): ModuleFile | false { 31 | const name = parseModuleName(filePath) 32 | const docs = parseModuleDocs(filePath) 33 | const examples = parseModuleExamples(filePath, docs) 34 | 35 | return { filePath, name, docs, examples } 36 | } 37 | 38 | function parseModuleName(filePath: string): string { 39 | return filePath.replace(/^.*\/src\/(.*?)\/\1.ts$/, '$1') 40 | } 41 | 42 | function parseModuleDocs(filePath: string): string { 43 | const fileContent = fs.readFileSync(filePath, 'utf-8') 44 | 45 | if (!fileContent.includes('/**')) { 46 | throw new Error(`The module at path '${filePath}' has no JSDoc`) 47 | } 48 | 49 | // Parse the initial JSDoc comment as the documentation 50 | return fileContent 51 | .split('\n') 52 | .filter((line) => line.startsWith(' *')) 53 | .map((line) => line.replace(/^ \*[ /]?/, '')) 54 | .join('\n') 55 | } 56 | 57 | function parseModuleExamples(filePath: string, docs: string): Array { 58 | const exampleMatch = docs.match(EXAMPLE_REGEX) 59 | 60 | if (!exampleMatch) { 61 | throw new Error(`The module at path '${filePath}' has no examples`) 62 | } 63 | 64 | return exampleMatch[1].split('\n\n').map(parseModuleExample) 65 | } 66 | 67 | function parseModuleExample(example: string): Example { 68 | // Get the code (the parts without "// -> " at the start) 69 | const code = example 70 | .split('\n') 71 | .filter((x) => !x.startsWith('// -> ')) 72 | .map((x) => x.replace('await ', '')) 73 | .join('\n') 74 | 75 | // Get the expected output (the parts with "// -> " at the start) 76 | const expected = example 77 | .split('\n') 78 | .filter((x) => x.startsWith('// -> ')) 79 | .map((x) => x.replace('// -> ', '')) 80 | .join('\n') 81 | 82 | return { 83 | code, 84 | expected: expected === '' || expected === 'undefined' ? undefined : eval(`(${expected})`), 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/percentile/percentile.spec.ts: -------------------------------------------------------------------------------- 1 | import { percentile } from './percentile' 2 | 3 | describe('percentile', () => { 4 | test('calculates the percentile of an array of numbers', () => { 5 | expect(percentile([1, 4, 2, 4, 0], 0.5)).toEqual(2) 6 | 7 | const values = [90, 85, 65, 72, 82, 96, 70, 79, 68, 84] 8 | expect(percentile(values, 1)).toEqual(96) 9 | expect(percentile(values, 0.95)).toEqual(93.3) 10 | expect(percentile(values, 0.9)).toEqual(90.6) 11 | expect(percentile(values, 0.8)).toEqual(86) 12 | expect(percentile(values, 0.75)).toEqual(84.75) 13 | expect(percentile(values, 0.7)).toEqual(84.3) 14 | expect(percentile(values, 0.6)).toEqual(82.8) 15 | expect(percentile(values, 0.5)).toEqual(80.5) 16 | expect(percentile(values, 0.25)).toEqual(70.5) 17 | expect(percentile(values, 0)).toEqual(65) 18 | 19 | expect(percentile([0], 0.5)).toEqual(0) 20 | expect(percentile([], 0.5)).toEqual(NaN) 21 | }) 22 | 23 | test('does not mutate the input', () => { 24 | const input = [3, 1, 2, 3] 25 | percentile(input, 0.5) 26 | expect(input).toEqual([3, 1, 2, 3]) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/percentile/percentile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### percentile(array, k) 3 | * 4 | * Compute the kth percentile of the values in an array. 5 | * 6 | * ```js 7 | * flocky.percentile([90, 85, 65, 72, 82, 96, 70, 79, 68, 84], 0.9) 8 | * // -> 90.6 9 | * ``` 10 | */ 11 | 12 | export function percentile(array: Array, k: number): number { 13 | const values = Array.from(array).sort((a, b) => a - b) 14 | const position = (values.length - 1) * k 15 | const baseIndex = Math.floor(position) 16 | 17 | if (values[baseIndex] === undefined) { 18 | return NaN 19 | } 20 | 21 | if (values[baseIndex + 1] === undefined) { 22 | return values[baseIndex] 23 | } 24 | 25 | const restPosition = position - baseIndex 26 | return values[baseIndex] + (values[baseIndex + 1] - values[baseIndex]) * restPosition 27 | } 28 | -------------------------------------------------------------------------------- /src/pick/BENCHMARK.md: -------------------------------------------------------------------------------- 1 | ### Benchmark for `pick` 2 | 3 | [Source for this benchmark](./pick.benchmark.ts) 4 | 5 | | | flocky | 6 | | ------------------------ | -------------------------------- | 7 | | 5 properties / 3 picked | **10,428,966 ops/sec (100.00%)** | 8 | | 26 properties / 3 picked | **7,488,734 ops/sec (100.00%)** | 9 | 10 | Generated at 2024-07-20 with Node.JS v20.9.0 11 | -------------------------------------------------------------------------------- /src/pick/pick.benchmark.ts: -------------------------------------------------------------------------------- 1 | import { Benchmark } from '../benchmarkHelper' 2 | import { pick } from './pick' 3 | 4 | const benchmark = new Benchmark('pick') 5 | 6 | benchmark.add({ 7 | library: 'flocky', 8 | input: '5 properties / 3 picked', 9 | func: () => pick({ a: 1, b: 2, c: 3, d: 4, e: 5 }, ['a', 'b', 'd']), 10 | }) 11 | 12 | benchmark.add({ 13 | library: 'flocky', 14 | input: '26 properties / 3 picked', 15 | func: () => { 16 | const object = { 17 | a: 1, 18 | b: 2, 19 | c: 3, 20 | d: 4, 21 | e: 5, 22 | f: 6, 23 | g: 7, 24 | h: 8, 25 | i: 9, 26 | j: 10, 27 | k: 11, 28 | l: 12, 29 | m: 13, 30 | n: 14, 31 | o: 15, 32 | p: 16, 33 | q: 17, 34 | r: 18, 35 | s: 19, 36 | t: 20, 37 | u: 21, 38 | v: 22, 39 | w: 23, 40 | x: 24, 41 | y: 25, 42 | z: 26, 43 | } 44 | 45 | return pick(object, ['a', 'b', 'd']) 46 | }, 47 | }) 48 | 49 | benchmark.run() 50 | -------------------------------------------------------------------------------- /src/pick/pick.spec.ts: -------------------------------------------------------------------------------- 1 | import { pick } from './pick' 2 | 3 | describe('pick', () => { 4 | test('picks the specified keys of the object', () => { 5 | const original = { a: 1, b: 2, c: 3, d: 4, e: 5 } 6 | const picked = pick(original, ['a', 'b', 'd']) 7 | 8 | expect(original).toEqual({ a: 1, b: 2, c: 3, d: 4, e: 5 }) 9 | expect(picked).toEqual({ a: 1, b: 2, d: 4 }) 10 | }) 11 | 12 | test('has the correct type behavior', () => { 13 | interface StackProps { 14 | children: string 15 | gap: string 16 | } 17 | 18 | function pickFromProps(props: StackProps): Pick { 19 | return pick(props, ['gap']) 20 | } 21 | 22 | expect(pickFromProps({ children: 'foo', gap: 'bar' })).toEqual({ gap: 'bar' }) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/pick/pick.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### pick(object, keys) 3 | * 4 | * Create an object composed of the specified `keys`. 5 | * 6 | * ```js 7 | * const object = { a: 1, b: 2, c: 3 } 8 | * flocky.pick(object, ['a', 'c']) 9 | * // -> { a: 1, c: 3 } 10 | * ``` 11 | */ 12 | 13 | export function pick(object: T, keys: Array): Pick { 14 | const result: Partial = {} 15 | 16 | for (let i = 0; i !== keys.length; i++) { 17 | result[keys[i]] = object[keys[i]] 18 | } 19 | 20 | return result as Pick 21 | } 22 | -------------------------------------------------------------------------------- /src/promisePool/promisePool.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '../sleep/sleep' 2 | import { expectApproximateDuration } from '../testHelpers' 3 | import { promisePool } from './promisePool' 4 | 5 | const PROMISE_FUNCTIONS = new Array(9).fill('').map((_, i) => async (): Promise => { 6 | await sleep(100) 7 | return i 8 | }) 9 | 10 | describe('promisePool', () => { 11 | test('runs the promise functions in parallel', async () => { 12 | const start = new Date() 13 | const result = await promisePool(PROMISE_FUNCTIONS, 100) 14 | const end = new Date() 15 | 16 | expect(result).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) 17 | expectApproximateDuration(start, end, 100) 18 | }) 19 | 20 | test('runs the promise functions in parallel with a limit', async () => { 21 | const start = new Date() 22 | const result = await promisePool(PROMISE_FUNCTIONS, 3) 23 | const end = new Date() 24 | 25 | expect(result).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) 26 | expectApproximateDuration(start, end, 3 * 100) 27 | }) 28 | 29 | test('runs the promise functions in series', async () => { 30 | const start = new Date() 31 | const result = await promisePool(PROMISE_FUNCTIONS, 1) 32 | const end = new Date() 33 | 34 | expect(result).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]) 35 | expectApproximateDuration(start, end, 9 * 100) 36 | }) 37 | 38 | test('errors when the first promise function errors', async () => { 39 | const ERRORING_PROMISE_FUNCTIONS = Object.assign([], PROMISE_FUNCTIONS, { 40 | 5: () => Promise.reject(new Error('Something went wrong.')), 41 | }) 42 | 43 | const start = new Date() 44 | let error: Error | undefined 45 | try { 46 | await promisePool(ERRORING_PROMISE_FUNCTIONS, 2) 47 | } catch (err) { 48 | if (!(err instanceof Error)) throw err 49 | error = err 50 | } 51 | const end = new Date() 52 | 53 | expect(error?.message).toEqual('Something went wrong.') 54 | expectApproximateDuration(start, end, 2 * 100) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/promisePool/promisePool.ts: -------------------------------------------------------------------------------- 1 | type PromiseFunction = () => Promise 2 | 3 | /** 4 | * ### promisePool(promiseFunctions, limit) 5 | * 6 | * Run multiple promise-returning functions in parallel with limited concurrency. 7 | * 8 | * ```js 9 | * await flocky.promisePool([ 10 | * () => Promise.resolve(1), 11 | * () => Promise.resolve(2), 12 | * () => Promise.resolve(3), 13 | * () => Promise.resolve(4), 14 | * ], 2) 15 | * // -> [1, 2, 3, 4] 16 | * ``` 17 | */ 18 | 19 | export async function promisePool( 20 | promiseFunctions: Array>, 21 | limit: number 22 | ): Promise> { 23 | const results: Array = [] 24 | 25 | const iterator = promiseFunctions.entries() 26 | const workers = new Array(limit).fill(iterator).map(async (it: typeof iterator) => { 27 | for (const [index, func] of it) { 28 | results[index] = await func() 29 | } 30 | }) 31 | 32 | await Promise.all(workers) 33 | return results 34 | } 35 | -------------------------------------------------------------------------------- /src/promiseTimeout/promiseTimeout.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '../sleep/sleep' 2 | import { expectApproximateDuration } from '../testHelpers' 3 | import { promiseTimeout } from './promiseTimeout' 4 | 5 | async function promiseWithDuration(value: T, duration: number): Promise { 6 | await sleep(duration) 7 | return value 8 | } 9 | 10 | describe('promiseTimeout', () => { 11 | test('runs the promise without hitting the timeout', async () => { 12 | const start = new Date() 13 | const result = await promiseTimeout(promiseWithDuration({ foo: 'bar' }, 100), 200) 14 | const end = new Date() 15 | 16 | expect(result).toEqual({ foo: 'bar' }) 17 | expectApproximateDuration(start, end, 100) 18 | 19 | // The return type has this property 20 | result.foo.toString() 21 | 22 | // @ts-expect-error The return type does not have this property 23 | expect(result.bar).toBeUndefined() 24 | }) 25 | 26 | test('rejects the promise when hitting the timeout', async () => { 27 | let error: Error | undefined 28 | 29 | const start = new Date() 30 | try { 31 | await promiseTimeout(promiseWithDuration('foo', 200), 100) 32 | } catch (err) { 33 | if (!(err instanceof Error)) throw err 34 | error = err 35 | } 36 | const end = new Date() 37 | 38 | expect(error?.message).toEqual('Promise timed out') 39 | expectApproximateDuration(start, end, 100) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/promiseTimeout/promiseTimeout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### promiseTimeout(promise, timeoutMs) 3 | * 4 | * Reject a promise if it does not resolve within `timeoutMs`. 5 | * 6 | * :warning: **When the timeout is hit, a promise rejection will be thrown. However, 7 | * since promises are not cancellable, the execution of the promise itself will continue 8 | * until it resolves or rejects.** 9 | * 10 | * ```js 11 | * await flocky.promiseTimeout(Promise.resolve(1), 10) 12 | * // -> 1 13 | * ``` 14 | */ 15 | 16 | export async function promiseTimeout(promise: Promise, timeoutMs: number): Promise { 17 | let timeoutId: NodeJS.Timeout 18 | const timeoutPromise = new Promise((_, reject) => { 19 | timeoutId = setTimeout(() => reject(new PromiseTimeoutError()), timeoutMs) 20 | }) 21 | 22 | return Promise.race([promise, timeoutPromise]).then((result) => { 23 | clearTimeout(timeoutId) 24 | return result as T 25 | }) 26 | } 27 | 28 | export class PromiseTimeoutError extends Error { 29 | constructor() { 30 | super('Promise timed out') 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/random/random.spec.ts: -------------------------------------------------------------------------------- 1 | import { mathRandom } from '../testHelpers' 2 | import { random } from './random' 3 | 4 | describe('random', () => { 5 | beforeEach(() => { 6 | mathRandom.setup() 7 | }) 8 | 9 | afterEach(() => { 10 | mathRandom.reset() 11 | }) 12 | 13 | test('generates a random integer', () => { 14 | expect(random(0, 10)).toEqual(8) 15 | }) 16 | 17 | test('generates a random float', () => { 18 | expect(random(0, 10, true)).toEqual(7.341312319841373) 19 | expect(random(3.5, 4.5)).toEqual(4.0153569814278525) 20 | }) 21 | }) 22 | 23 | describe('random (fuzzing)', () => { 24 | const ITERATIONS = 1000 25 | 26 | test('generates valid integers', () => { 27 | for (let i = 0; i !== ITERATIONS; i++) { 28 | const number = random(2, 10) 29 | expect(number >= 2 && number <= 10).toBeTruthy() 30 | expect(number % 1 === 0).toBeTruthy() 31 | } 32 | }) 33 | 34 | test('generates valid floats', () => { 35 | for (let i = 0; i !== ITERATIONS; i++) { 36 | const number = random(2.5, 5.75) 37 | expect(number >= 2.5 && number <= 5.75).toBeTruthy() 38 | } 39 | }) 40 | 41 | test('generates valid integers with minimum and maximum bounds', () => { 42 | const isInBounds = (x: number): boolean => 43 | x >= Number.MIN_SAFE_INTEGER && x <= Number.MAX_SAFE_INTEGER 44 | 45 | for (let i = 0; i !== ITERATIONS; i++) { 46 | const number = random(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER) 47 | 48 | expect(Number.isNaN(number)).toBeFalsy() 49 | expect(isInBounds(number)).toBeTruthy() 50 | } 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/random/random.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### random(lower, upper, float?) 3 | * 4 | * Generate a random number between `lower` and `upper` (inclusive). 5 | * If `float` is true or `lower` or `upper` is a float, a float is returned instead of an integer. 6 | * 7 | * ```js 8 | * flocky.random(1, 10) 9 | * // -> 8 10 | * 11 | * flocky.random(1, 20, true) 12 | * // -> 14.94849340769861 13 | * 14 | * flocky.random(2.5, 3.5) 15 | * // -> 3.2341312319841373 16 | * ``` 17 | */ 18 | 19 | export function random(lower: number, upper: number, float?: boolean): number { 20 | if (float || lower % 1 || upper % 1) { 21 | return randomFloat(lower, upper) 22 | } 23 | 24 | return randomInteger(lower, upper) 25 | } 26 | 27 | function randomFloat(lower: number, upper: number): number { 28 | return Math.random() * (upper - lower) + lower 29 | } 30 | 31 | function randomInteger(lower: number, upper: number): number { 32 | return Math.floor(Math.random() * (upper - lower + 1) + lower) 33 | } 34 | -------------------------------------------------------------------------------- /src/randomString/randomString.spec.ts: -------------------------------------------------------------------------------- 1 | import { mathRandom } from '../testHelpers' 2 | import { randomString } from './randomString' 3 | 4 | describe('randomString', () => { 5 | beforeEach(() => { 6 | mathRandom.setup() 7 | }) 8 | 9 | afterEach(() => { 10 | mathRandom.reset() 11 | }) 12 | 13 | test('generates a random string', () => { 14 | expect(randomString(5)).toEqual('tfl0g') 15 | expect(randomString(10)).toEqual('9JmILmsJRU') 16 | expect(randomString(3)).toEqual('vPY') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /src/randomString/randomString.ts: -------------------------------------------------------------------------------- 1 | const CHARACTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 2 | 3 | /** 4 | * ### randomString(length) 5 | * 6 | * Generate a random alphanumeric string with `length` characters. 7 | * 8 | * ```js 9 | * flocky.randomString(5) 10 | * // -> 'tfl0g' 11 | * ``` 12 | */ 13 | 14 | export function randomString(length: number): string { 15 | let string = '' 16 | 17 | for (let i = 0; i !== length; i++) { 18 | const index = Math.floor(Math.random() * CHARACTERS.length) 19 | string += CHARACTERS.charAt(index) 20 | } 21 | 22 | return string 23 | } 24 | -------------------------------------------------------------------------------- /src/range/range.spec.ts: -------------------------------------------------------------------------------- 1 | import { range } from './range' 2 | 3 | describe('range', () => { 4 | test('generates a range of numbers', () => { 5 | expect(range(0, 1)).toEqual([0, 1]) 6 | expect(range(1, 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 7 | 8 | expect(range(-4, -1)).toEqual([-4, -3, -2, -1]) 9 | expect(range(-1, -6)).toEqual([-1, -2, -3, -4, -5, -6]) 10 | }) 11 | 12 | test('generates a range of numbers with a custom step', () => { 13 | expect(range(0, 10, 2)).toEqual([0, 2, 4, 6, 8, 10]) 14 | expect(range(-1, -11, 2)).toEqual([-1, -3, -5, -7, -9, -11]) 15 | }) 16 | 17 | test('does not generate an out of bounds range', () => { 18 | expect(range(0, 0)).toEqual([0]) 19 | expect(range(0, 12, 10)).toEqual([0, 10]) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/range/range.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### range(start, end, step?) 3 | * 4 | * Generate an array of numbers progressing from `start` up to and including `end`. 5 | * 6 | * ```js 7 | * flocky.range(0, 5) 8 | * // -> [0, 1, 2, 3, 4, 5] 9 | * 10 | * flocky.range(-5, -10) 11 | * // -> [-5, -6, -7, -8, -9, -10] 12 | * 13 | * flocky.range(-6, -12, 2) 14 | * // -> [-6, -8, -10, -12] 15 | * ``` 16 | */ 17 | 18 | export function range(start: number, end: number, step = 1): Array { 19 | const array = [] 20 | const positive = start <= end 21 | let value = start 22 | 23 | if (positive) { 24 | while (value <= end) { 25 | array.push(value) 26 | value += step 27 | } 28 | } else { 29 | while (value >= end) { 30 | array.push(value) 31 | value -= step 32 | } 33 | } 34 | 35 | return array 36 | } 37 | -------------------------------------------------------------------------------- /src/roundTo/roundTo.spec.ts: -------------------------------------------------------------------------------- 1 | import { roundTo } from './roundTo' 2 | 3 | describe('roundTo', () => { 4 | test('rounds the number to the expected precision', () => { 5 | // General usage with integers 6 | expect(roundTo(1, 0)).toEqual(1) 7 | expect(roundTo(1, 3)).toEqual(1) 8 | 9 | // General usage with floats 10 | expect(roundTo(0.129, 1)).toEqual(0.1) 11 | expect(roundTo(0.129, 2)).toEqual(0.13) 12 | expect(roundTo(0.129, 3)).toEqual(0.129) 13 | 14 | // Edge cases for floating point weirdness 15 | expect(roundTo(1.005, 2)).toEqual(1.01) 16 | expect(roundTo(1.005, 0)).toEqual(1) 17 | 18 | // Negative precision 19 | expect(roundTo(111.1, -2)).toEqual(100) 20 | 21 | // Negative numbers 22 | expect(roundTo(-3.51, 1)).toEqual(-3.5) 23 | expect(roundTo(-0.375, 2)).toEqual(-0.38) 24 | 25 | // Edge cases for maximum floating point precision 26 | expect(roundTo(1e20, 1)).toEqual(1e20) 27 | expect(roundTo(10000000000000.123, 8)).toEqual(10000000000000.123) 28 | expect(roundTo(0.37542323423423432432432432432, 8)).toEqual(0.37542323) // eslint-disable-line no-loss-of-precision 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/roundTo/roundTo.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### roundTo(number, precision) 3 | * 4 | * Round a floating point number to `precision` decimal places. 5 | * 6 | * ```js 7 | * flocky.roundTo(3.141592653589, 4) 8 | * // -> 3.1416 9 | * 10 | * flocky.roundTo(1.005, 2) 11 | * // -> 1.01 12 | * 13 | * flocky.roundTo(1111.1, -2) 14 | * // -> 1100 15 | * ``` 16 | * 17 | *
18 | * Implementation Details 19 | * 20 | * This method avoids floating-point errors by adjusting the exponent part of 21 | * the string representation of a number instead of multiplying and dividing 22 | * with powers of 10. The implementation is based on [this example](https://stackoverflow.com/a/60098416) 23 | * by Lam Wei Li. 24 | *
25 | */ 26 | 27 | export function roundTo(number: number, precision: number): number { 28 | const isNegative = number < 0 29 | 30 | // We can only work with positive numbers in the next steps, use the absolute 31 | number = Math.abs(number) 32 | 33 | // Shift the decimal point to the right by `precision` places and round the result 34 | // e.g. 1.456745 with precision 3 -> 1456.745 -> 1457 35 | number = Math.round(shift(number, precision)) 36 | 37 | // Shift the decimal point back to the left by `precision` places 38 | // e.g. 1457 with precision 3 -> 1.457 39 | number = shift(number, -precision) 40 | 41 | // If the original number was negative, the rounded number is too 42 | if (isNegative) { 43 | number = -number 44 | } 45 | 46 | return number 47 | } 48 | 49 | // Shift the decimal point of a `number` by `exponent` places 50 | function shift(number: number, exponent: number): number { 51 | const [numberBase, numberExponent] = `${number}e`.split('e') 52 | return Number(`${numberBase}e${Number(numberExponent) + exponent}`) 53 | } 54 | -------------------------------------------------------------------------------- /src/sample/sample.spec.ts: -------------------------------------------------------------------------------- 1 | import { mathRandom } from '../testHelpers' 2 | import { sample } from './sample' 3 | 4 | describe('sample', () => { 5 | beforeEach(() => { 6 | mathRandom.setup() 7 | }) 8 | 9 | afterEach(() => { 10 | mathRandom.reset() 11 | }) 12 | 13 | test('samples primitive arrays', () => { 14 | const original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 15 | const element = sample(original) 16 | 17 | expect(original).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 18 | expect(element).toEqual(8) 19 | }) 20 | 21 | test('samples object arrays', () => { 22 | const original = [{ a: 1 }, { b: 1 }, { c: 1 }, { d: 1 }, { e: 1 }] 23 | const element = sample(original) 24 | 25 | expect(original).toEqual([{ a: 1 }, { b: 1 }, { c: 1 }, { d: 1 }, { e: 1 }]) 26 | expect(element).toEqual({ d: 1 }) 27 | }) 28 | }) 29 | 30 | describe('sample (fuzzing)', () => { 31 | const ITERATIONS = 1000 32 | 33 | test('does not get out of bound elements', () => { 34 | for (let i = 0; i !== ITERATIONS; i++) { 35 | const number = sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 36 | expect(number).toBeTruthy() 37 | } 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/sample/sample.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### sample(array) 3 | * 4 | * Get a random element from the `array`. 5 | * 6 | * ```js 7 | * flocky.sample([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 8 | * // -> 8 9 | * ``` 10 | */ 11 | 12 | export function sample(array: Array): T { 13 | const index = Math.floor(Math.random() * array.length) 14 | return array[index] 15 | } 16 | -------------------------------------------------------------------------------- /src/shuffle/shuffle.spec.ts: -------------------------------------------------------------------------------- 1 | import { mathRandom } from '../testHelpers' 2 | import { shuffle } from './shuffle' 3 | 4 | describe('shuffle', () => { 5 | beforeEach(() => { 6 | mathRandom.setup() 7 | }) 8 | 9 | afterEach(() => { 10 | mathRandom.reset() 11 | }) 12 | 13 | test('shuffles primitive arrays', () => { 14 | const original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 15 | const shuffled = shuffle(original) 16 | 17 | expect(original).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 18 | expect(shuffled).toEqual([3, 7, 2, 1, 10, 4, 6, 9, 5, 8]) 19 | }) 20 | 21 | test('shuffles object arrays', () => { 22 | const original = [{ a: 1 }, { b: 1 }, { c: 1 }, { d: 1 }, { e: 1 }] 23 | const shuffled = shuffle(original) 24 | 25 | expect(original).toEqual([{ a: 1 }, { b: 1 }, { c: 1 }, { d: 1 }, { e: 1 }]) 26 | expect(shuffled).toEqual([{ a: 1 }, { e: 1 }, { b: 1 }, { c: 1 }, { d: 1 }]) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/shuffle/shuffle.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### shuffle(array) 3 | * 4 | * Create an array of shuffled values. 5 | * 6 | * ```js 7 | * flocky.shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 8 | * // -> [3, 7, 2, 1, 10, 4, 6, 9, 5, 8] 9 | * ``` 10 | * 11 | *
12 | * Implementation Details 13 | * 14 | * This method uses a modern version of the 15 | * [Fisher-Yates shuffle](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm). 16 | *
17 | */ 18 | 19 | export function shuffle(array: Array): Array { 20 | // Create a copy of the array so we don't mutate the input 21 | array = array.concat() 22 | 23 | // Treat the end of the array as shuffled and the start as unshuffled. 24 | // The shuffling is accomplished by going through the array from end to start, 25 | // generating a random index up to the current index, and then swapping the 26 | // place of the element at that random index with the current index. 27 | // 28 | // ['a', 'b', 'c', 'd', 'e'] 29 | // ^ Random index between 0 - 4 (e.g. 2: swap 'e' with 'c') 30 | // ['a', 'b', 'e', 'd', 'c'] 31 | // ^ Random index between 0 - 3 (e.g. 1: swap 'd' with 'b') 32 | // ['a', 'd', 'e', 'b', 'c'] 33 | // ^ Random index between 0 - 2 (e.g. 0: swap 'e' with 'a') 34 | // ['e', 'd', 'a', 'b', 'c'] 35 | // ^ Random index between 0 - 1 (e.g. 0: swap 'd' with 'e') 36 | // ['d', 'e', 'a', 'b', 'c'] 37 | for (let i = array.length - 1; i > 0; i--) { 38 | const j = Math.floor(Math.random() * (i + 1)) 39 | 40 | const tmp = array[i] 41 | array[i] = array[j] 42 | array[j] = tmp 43 | } 44 | 45 | return array 46 | } 47 | -------------------------------------------------------------------------------- /src/sleep/sleep.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectApproximateDuration } from '../testHelpers' 2 | import { sleep } from './sleep' 3 | 4 | describe('sleep', () => { 5 | test('waits for the specified time', async () => { 6 | const start = new Date() 7 | await sleep(100) 8 | const end = new Date() 9 | 10 | expectApproximateDuration(start, end, 100) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /src/sleep/sleep.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### sleep(ms) 3 | * 4 | * Return a promise that waits for `ms` milliseconds before resolving. 5 | * 6 | * ```js 7 | * await flocky.sleep(25) 8 | * ``` 9 | */ 10 | 11 | export function sleep(ms: number): Promise { 12 | return new Promise((resolve) => setTimeout(resolve, ms)) 13 | } 14 | -------------------------------------------------------------------------------- /src/slugify/slugify.spec.ts: -------------------------------------------------------------------------------- 1 | import { slugify } from './slugify' 2 | 3 | describe('slugify', () => { 4 | test('generates correct slugs', () => { 5 | expect(slugify('Hi There')).toEqual('hi-there') 6 | expect(slugify('Hi-- There')).toEqual('hi-there') 7 | expect(slugify(' Hi 123_ There ')).toEqual('hi-123-there') 8 | expect(slugify(' #;123-0sdg--+++asofinasg.d/f23XXX£948// ')).toEqual( 9 | '123-0sdg-asofinasg-d-f23xxx-948' 10 | ) 11 | expect(slugify('unicode ♥ is ☢')).toEqual('unicode-is') 12 | expect(slugify('---trim---')).toEqual('trim') 13 | }) 14 | }) 15 | -------------------------------------------------------------------------------- /src/slugify/slugify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### slugify(string) 3 | * 4 | * Generate a URL-safe slug of a string. 5 | * 6 | * ```js 7 | * flocky.slugify(' Issue #123 is _important_! :)') 8 | * // -> 'issue-123-is-important' 9 | * ``` 10 | */ 11 | 12 | export function slugify(string: string): string { 13 | return string 14 | .trim() 15 | .toLowerCase() 16 | .replace(/[^a-z0-9]+/g, '-') // Replace all clusters of non-word characters with a single "-" 17 | .replace(/^-|-$/g, '') // Trim "-" from start and end 18 | } 19 | -------------------------------------------------------------------------------- /src/sum/sum.spec.ts: -------------------------------------------------------------------------------- 1 | import { sum } from './sum' 2 | 3 | describe('sum', () => { 4 | test('calculates the sum of an array of numbers', () => { 5 | expect(sum([1, 4, 2, 0])).toEqual(7) 6 | expect(sum([9, 4, 3, 7, 79, -60])).toEqual(42) 7 | expect(sum([])).toEqual(0) 8 | }) 9 | 10 | test('does not mutate the input', () => { 11 | const input = [1, 2, 3] 12 | sum(input) 13 | expect(input).toEqual([1, 2, 3]) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/sum/sum.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### sum(array) 3 | * 4 | * Compute the sum of the values in an array. 5 | * 6 | * ```js 7 | * flocky.sum([1, 4, 2, -4, 0]) 8 | * // -> 3 9 | * ``` 10 | */ 11 | 12 | export function sum(array: Array): number { 13 | return array.reduce((a, b) => a + b, 0) 14 | } 15 | -------------------------------------------------------------------------------- /src/testHelpers.ts: -------------------------------------------------------------------------------- 1 | const RANDOM_OUTPUT = [ 2 | 0.7341312319841373, 0.5153569814278522, 0.6039244693324568, 0.8485123814547479, 3 | 0.5305323323446391, 0.9936904462715297, 0.152741118641178, 0.6284452050928289, 0.1333952722446896, 4 | 0.19323989178442735, 0.6210946032149509, 0.715356956262492, 0.1601444486850756, 5 | 0.2826657106671773, 0.3360975043209571, 0.7686446730864509, 0.2482518764495567, 6 | 0.39590294569110873, 0.8810226597671855, 0.8645898520605233, 0.08819117131655063, 7 | 0.9746880765364763, 0.8191862710617215, 0.6093775445189631, 0.5680783823402815, 8 | 0.4094440218703661, 0.03730391719730841, 0.17578915337316725, 0.8865419358339801, 9 | 0.2227008300658413, 10 | ] 11 | 12 | let randomIndex = 0 13 | let globalMathRandom = Math.random 14 | 15 | export const mathRandom = { 16 | setup: (): void => { 17 | randomIndex = 0 18 | globalMathRandom = Math.random 19 | Math.random = (): number => RANDOM_OUTPUT[randomIndex++] 20 | }, 21 | reset: (): void => { 22 | Math.random = globalMathRandom 23 | }, 24 | } 25 | 26 | let globalDateNow = Date.now 27 | 28 | export const dateNow = { 29 | setup: (): void => { 30 | globalDateNow = Date.now 31 | Date.now = (): number => 1551647486832 32 | }, 33 | reset: (): void => { 34 | Date.now = globalDateNow 35 | }, 36 | } 37 | 38 | export function expectApproximateDuration(start: Date, end: Date, duration: number): void { 39 | const BUFFER = 25 40 | 41 | expect(end.getTime() - start.getTime()).toBeGreaterThan(duration - BUFFER) 42 | expect(end.getTime() - start.getTime()).toBeLessThan(duration + BUFFER) 43 | } 44 | -------------------------------------------------------------------------------- /src/throttle/throttle.spec.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '../sleep/sleep' 2 | import { throttle } from './throttle' 3 | 4 | describe('throttle', () => { 5 | test('throttles the function call', async () => { 6 | const func = jest.fn() 7 | 8 | const throttledFunc = throttle(func, 25) 9 | throttledFunc('a') 10 | throttledFunc('ab') 11 | throttledFunc('abc') 12 | throttledFunc('abcd') 13 | 14 | expect(func.mock.calls).toEqual([['a']]) 15 | await sleep(50) 16 | expect(func.mock.calls).toEqual([['a'], ['abcd']]) 17 | 18 | throttledFunc('abcde') 19 | throttledFunc('abcdef') 20 | 21 | expect(func.mock.calls).toEqual([['a'], ['abcd'], ['abcde']]) 22 | await sleep(25) 23 | expect(func.mock.calls).toEqual([['a'], ['abcd'], ['abcde'], ['abcdef']]) 24 | }) 25 | 26 | test('has the correct type', () => { 27 | const func = (a: number, b: number): number => { 28 | return a + b 29 | } 30 | 31 | const throttledFunc = throttle(func, 250) 32 | 33 | // @ts-expect-error The first argument has to be a number 34 | throttledFunc('a', 2) 35 | 36 | // @ts-expect-error The second argument has to be a number 37 | throttledFunc(2, 'a') 38 | 39 | // @ts-expect-error The return value is void 40 | throttledFunc(2, 2)?.concat() 41 | 42 | // @ts-expect-error The return value is void 43 | throttledFunc(2, 2)?.toFixed() 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /src/throttle/throttle.ts: -------------------------------------------------------------------------------- 1 | import { TAnyFunction } from '../typeHelpers' 2 | 3 | /** 4 | * ### throttle(func, wait) 5 | * 6 | * Create a throttled function that invokes `func` at most every `wait` milliseconds. 7 | * If the invocation is throttled, `func` will be invoked with the last arguments provided. 8 | * 9 | * ```js 10 | * const func = () => console.log('Heavy processing happening') 11 | * const throttledFunc = flocky.throttle(func, 250) 12 | * ``` 13 | */ 14 | 15 | type FunctionWithVoidReturn> = (...args: Parameters) => void 16 | 17 | export function throttle>( 18 | func: TFunc, 19 | wait: number 20 | ): FunctionWithVoidReturn { 21 | let timeoutID: NodeJS.Timeout | null = null 22 | let lastCall = 0 23 | 24 | return function (this: unknown, ...args: Array) { 25 | if (timeoutID) clearTimeout(timeoutID) 26 | 27 | const remainingWait = wait - (Date.now() - lastCall) 28 | 29 | const callFunc = (): void => { 30 | func.apply(this, args) 31 | lastCall = Date.now() 32 | } 33 | 34 | // Immediately call the function if we don't have to throttle it 35 | if (remainingWait <= 0 || remainingWait > wait) { 36 | return callFunc() 37 | } 38 | 39 | timeoutID = setTimeout(() => callFunc(), remainingWait) 40 | } as FunctionWithVoidReturn 41 | } 42 | -------------------------------------------------------------------------------- /src/toMap/toMap.spec.ts: -------------------------------------------------------------------------------- 1 | import { toMap } from './toMap' 2 | 3 | describe('toMap', () => { 4 | test('generates a map with no target', () => { 5 | const input = [ 6 | { id: 1, name: 'Anna', age: 64 }, 7 | { id: 2, name: 'Bertha', age: 57 }, 8 | { 9 | id: 3, 10 | name: 'Chris', 11 | age: 19, 12 | alignment: { evil: 3, neutral: 1, good: 0 }, 13 | }, 14 | ] 15 | 16 | expect(toMap(input, 'id')).toEqual({ 17 | 1: { id: 1, name: 'Anna', age: 64 }, 18 | 2: { id: 2, name: 'Bertha', age: 57 }, 19 | 3: { 20 | id: 3, 21 | name: 'Chris', 22 | age: 19, 23 | alignment: { evil: 3, neutral: 1, good: 0 }, 24 | }, 25 | }) 26 | 27 | expect(toMap(input, 'name')).toEqual({ 28 | Anna: { id: 1, name: 'Anna', age: 64 }, 29 | Bertha: { id: 2, name: 'Bertha', age: 57 }, 30 | Chris: { 31 | id: 3, 32 | name: 'Chris', 33 | age: 19, 34 | alignment: { evil: 3, neutral: 1, good: 0 }, 35 | }, 36 | }) 37 | 38 | expect(toMap(input, 'age')).toEqual({ 39 | 64: { id: 1, name: 'Anna', age: 64 }, 40 | 57: { id: 2, name: 'Bertha', age: 57 }, 41 | 19: { 42 | id: 3, 43 | name: 'Chris', 44 | age: 19, 45 | alignment: { evil: 3, neutral: 1, good: 0 }, 46 | }, 47 | }) 48 | }) 49 | 50 | test('generates a map with a target', () => { 51 | const input = [ 52 | { id: 1, name: 'Anna', age: 64 }, 53 | { id: 2, name: 'Bertha', age: 57 }, 54 | { id: 3, name: 'Chris', age: 19 }, 55 | ] 56 | 57 | expect(toMap(input, 'id', 'age')).toEqual({ 58 | 1: 64, 59 | 2: 57, 60 | 3: 19, 61 | }) 62 | 63 | expect(toMap(input, 'name', 'name')).toEqual({ 64 | Anna: 'Anna', 65 | Bertha: 'Bertha', 66 | Chris: 'Chris', 67 | }) 68 | 69 | expect(toMap(input, 'age', 'id')).toEqual({ 70 | 64: 1, 71 | 57: 2, 72 | 19: 3, 73 | }) 74 | }) 75 | 76 | test('filters elements if the key does not exist', () => { 77 | const input = [ 78 | { id: 1, name: 'Anna', age: 64 }, 79 | { id: 2, name: 'Bertha', age: 57 }, 80 | { name: 'Chris Clone', age: 80 }, 81 | { id: 3, name: 'Chris', age: 19 }, 82 | ] 83 | 84 | expect(toMap(input, 'id', 'age')).toEqual({ 85 | 1: 64, 86 | 2: 57, 87 | 3: 19, 88 | }) 89 | }) 90 | 91 | test('does not mutate the input', () => { 92 | const input = [ 93 | { id: 1, name: 'Anna' }, 94 | { id: 2, name: 'Bertha' }, 95 | { id: 3, name: 'Chris' }, 96 | ] 97 | 98 | toMap(input, 'id') 99 | expect(input).toEqual([ 100 | { id: 1, name: 'Anna' }, 101 | { id: 2, name: 'Bertha' }, 102 | { id: 3, name: 'Chris' }, 103 | ]) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /src/toMap/toMap.ts: -------------------------------------------------------------------------------- 1 | // Return all keys of an object that have value types we can use as a map key 2 | type MappableKeys = NonNullable< 3 | { 4 | [K in keyof T]: T[K] extends string | number | undefined ? K : never 5 | }[keyof T] 6 | > 7 | 8 | /** 9 | * ### toMap(array, key, target?) 10 | * 11 | * Create a lookup map out of an `array` of objects, with a lookup `key` and an optional `target`. 12 | * 13 | * ```js 14 | * flocky.toMap( 15 | * [ 16 | * { id: 1, name: 'Stanley', age: 64 }, 17 | * { id: 2, name: 'Juliet', age: 57 }, 18 | * { id: 3, name: 'Alex', age: 19 } 19 | * ], 20 | * 'id' 21 | * ) 22 | * // -> { 23 | * // -> 1: { id: 1, name: 'Stanley', age: 64 }, 24 | * // -> 2: { id: 2, name: 'Juliet', age: 57 }, 25 | * // -> 3: { id: 3, name: 'Alex', age: 19 } 26 | * // -> } 27 | * 28 | * flocky.toMap( 29 | * [ 30 | * { id: 1, name: 'Stanley', age: 64 }, 31 | * { id: 2, name: 'Juliet', age: 57 }, 32 | * { id: 3, name: 'Alex', age: 19 } 33 | * ], 34 | * 'name', 35 | * 'age' 36 | * ) 37 | * // -> { Stanley: 64, Juliet: 57, Alex: 19 } 38 | * ``` 39 | */ 40 | 41 | export function toMap>( 42 | array: Array, 43 | key: Key 44 | ): { [key: string]: Element | undefined } 45 | 46 | export function toMap< 47 | Element extends object, 48 | Key extends MappableKeys, 49 | Target extends keyof Element, 50 | >(array: Array, key: Key, target: Target): { [key: string]: Element[Target] | undefined } 51 | 52 | export function toMap< 53 | Element extends object, 54 | Key extends MappableKeys, 55 | Target extends keyof Element, 56 | >( 57 | array: Array, 58 | key: Key, 59 | target?: Target 60 | ): { [key: string]: Element | Element[Target] | undefined } { 61 | const map: { [key: string]: Element | Element[Target] | undefined } = {} 62 | 63 | array.map((element) => { 64 | if (!element[key]) return 65 | map[element[key] as unknown as string] = target ? element[target] : element 66 | }) 67 | 68 | return map 69 | } 70 | -------------------------------------------------------------------------------- /src/typeHelpers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export type JSONValue = string | number | boolean | null | undefined | JSONObject | JSONArray 4 | type JSONObject = { [key: string]: JSONValue } 5 | type JSONArray = Array 6 | 7 | export type TAnyFunction = (...args: Array) => TReturn 8 | 9 | export type Simplify = T extends unknown ? { [K in keyof T]: Simplify } : never 10 | 11 | export type RecursiveUnionToIntersection = { 12 | [TKey in keyof TObjectWithUnions]: TObjectWithUnions[TKey] extends object 13 | ? RecursiveUnionToIntersection> 14 | : TObjectWithUnions[TKey] 15 | } 16 | 17 | export type UnionToIntersection = ( 18 | TUnion extends any ? (k: TUnion) => void : never 19 | ) extends (k: infer TIntersection) => void 20 | ? TIntersection 21 | : never 22 | -------------------------------------------------------------------------------- /src/unflatten/unflatten.spec.ts: -------------------------------------------------------------------------------- 1 | import { unflatten } from './unflatten' 2 | 3 | describe('unflatten', () => { 4 | test('unflattens the object', () => { 5 | expect(unflatten({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 }) 6 | 7 | expect(unflatten({ 'a.b': 1 })).toEqual({ a: { b: 1 } }) 8 | expect(unflatten({ 'a.b': 1, 'a.c': 2 })).toEqual({ a: { b: 1, c: 2 } }) 9 | expect(unflatten({ 'a.b': 1, 'a.c': 2, 'd.e': 3 })).toEqual({ a: { b: 1, c: 2 }, d: { e: 3 } }) 10 | 11 | expect(unflatten({ 'a.b.c': 1 })).toEqual({ a: { b: { c: 1 } } }) 12 | expect(unflatten({ 'a.b.c': 1, 'a.b.d': 2 })).toEqual({ a: { b: { c: 1, d: 2 } } }) 13 | expect(unflatten({ 'a.b.c': 1, 'a.b.d': 2, 'e.f.g': 3 })).toEqual({ 14 | a: { b: { c: 1, d: 2 } }, 15 | e: { f: { g: 3 } }, 16 | }) 17 | 18 | // Check that the types are correct 19 | const result = unflatten({ 'a.a.a': 1, 'a.b.c': 1, 'a.b.d': 2, 'e.f.g': 3 }) 20 | expect(result.a.a.a + 1).toEqual(2) 21 | expect(result.a.b.c + 1).toEqual(2) 22 | expect(result.a.b.d + 1).toEqual(3) 23 | expect(result.e.f.g + 1).toEqual(4) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/unflatten/unflatten.ts: -------------------------------------------------------------------------------- 1 | import { RecursiveUnionToIntersection, Simplify } from '../typeHelpers' 2 | 3 | /** 4 | * ### unflatten(object) 5 | * 6 | * Unflattens an object with dot notation keys into a nested object. 7 | * 8 | * ```js 9 | * flocky.unflatten({ 'a.b': 1, 'a.c': 2, 'd.e.f': 3 }) 10 | * // -> { a: { b: 1, c: 2 }, d: { e: { f: 3 } } } 11 | * ``` 12 | */ 13 | 14 | type UnflattenObject> = { 15 | [TKey in keyof TObject as TKey extends `${infer TKeyPrefix}.${string}` 16 | ? TKeyPrefix 17 | : TKey]: TKey extends `${string}.${infer TKeySuffix}` 18 | ? UnflattenObject<{ [key in TKeySuffix]: TObject[TKey] }> 19 | : TObject[TKey] 20 | } extends infer TResult 21 | ? { [TResultKey in keyof TResult]: TResult[TResultKey] } 22 | : never 23 | 24 | export function unflatten>( 25 | object: TObject 26 | ): Simplify>> { 27 | const result: Record = {} 28 | 29 | for (const key in object) { 30 | const keys = key.split('.') 31 | let current = result 32 | 33 | for (let i = 0; i < keys.length; i++) { 34 | const part = keys[i] 35 | 36 | if (i === keys.length - 1) { 37 | current[part] = object[key] 38 | } else { 39 | if (!current[part]) current[part] = {} 40 | current = current[part] as Record 41 | } 42 | } 43 | } 44 | 45 | return result as Simplify>> 46 | } 47 | -------------------------------------------------------------------------------- /src/unique/BENCHMARK.md: -------------------------------------------------------------------------------- 1 | ### Benchmark for `unique` 2 | 3 | [Source for this benchmark](./unique.benchmark.ts) 4 | 5 | | | es6 filter | lodash | flocky | 6 | | ----------- | ------------------------ | ------------------------ | ----------------------------- | 7 | | large array | 40 ops/sec (2.76%) | 1,313 ops/sec (90.49%) | **1,451 ops/sec (100.00%)** | 8 | | small array | 205,293 ops/sec (85.34%) | 187,378 ops/sec (77.89%) | **240,559 ops/sec (100.00%)** | 9 | 10 | Generated at 2024-07-20 with Node.JS v20.9.0 11 | -------------------------------------------------------------------------------- /src/unique/unique.benchmark.ts: -------------------------------------------------------------------------------- 1 | import lodash from 'lodash' 2 | import { Benchmark } from '../benchmarkHelper' 3 | import { unique } from './unique' 4 | 5 | function generateArrayOfSize(size: number): Array { 6 | const array: Array = [] 7 | 8 | for (let i = 0; i !== size; i++) { 9 | array.push(Math.random()) 10 | } 11 | 12 | return array 13 | } 14 | 15 | const LARGE_ARRAY = generateArrayOfSize(10000) 16 | const SMALL_ARRAY = generateArrayOfSize(100) 17 | console.log('Setup finished') 18 | 19 | const benchmark = new Benchmark('unique') 20 | 21 | benchmark.add({ 22 | library: 'es6 filter', 23 | input: 'large array', 24 | func: () => LARGE_ARRAY.filter((x, i, self) => self.indexOf(x) === i), 25 | }) 26 | 27 | benchmark.add({ 28 | library: 'es6 filter', 29 | input: 'small array', 30 | func: () => SMALL_ARRAY.filter((x, i, self) => self.indexOf(x) === i), 31 | }) 32 | 33 | benchmark.add({ 34 | library: 'lodash', 35 | input: 'large array', 36 | func: () => lodash.uniq(LARGE_ARRAY), 37 | }) 38 | 39 | benchmark.add({ 40 | library: 'lodash', 41 | input: 'small array', 42 | func: () => lodash.uniq(SMALL_ARRAY), 43 | }) 44 | 45 | benchmark.add({ 46 | library: 'flocky', 47 | input: 'large array', 48 | func: () => unique(LARGE_ARRAY), 49 | }) 50 | 51 | benchmark.add({ 52 | library: 'flocky', 53 | input: 'small array', 54 | func: () => unique(SMALL_ARRAY), 55 | }) 56 | 57 | benchmark.run() 58 | -------------------------------------------------------------------------------- /src/unique/unique.spec.ts: -------------------------------------------------------------------------------- 1 | import { unique } from './unique' 2 | 3 | describe('unique', () => { 4 | test('filters the unique occurrences of an array of numbers', () => { 5 | expect(unique([1, 4, 2, 0])).toEqual([1, 4, 2, 0]) 6 | expect(unique([1, 1, 4, 2, 0, 2, 0])).toEqual([1, 4, 2, 0]) 7 | }) 8 | 9 | test('filters the unique occurrences of an array of strings', () => { 10 | expect(unique(['foo', 'bar', 'foo', 'foobar', 'foo', 'foo'])).toEqual(['foo', 'bar', 'foobar']) 11 | }) 12 | 13 | test('filters the unique occurrences of an array of objects with an identity function', () => { 14 | const input = [ 15 | { id: 1, name: 'Foo1' }, 16 | { id: 2, name: 'Foo2' }, 17 | { id: 1, name: 'Foo3' }, 18 | { id: 3, name: 'Foo4' }, 19 | { id: 2, name: 'Foo5' }, 20 | ] 21 | 22 | const expected = [ 23 | { id: 1, name: 'Foo1' }, 24 | { id: 2, name: 'Foo2' }, 25 | { id: 3, name: 'Foo4' }, 26 | ] 27 | 28 | expect(unique(input, (element) => element.id)).toEqual(expected) 29 | }) 30 | 31 | test('does not mutate the input', () => { 32 | const input = [1, 2, 3, 1] 33 | unique(input) 34 | expect(input).toEqual([1, 2, 3, 1]) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/unique/unique.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ### unique(array, identity?) 3 | * 4 | * Create a duplicate-free version of an array, in which only the first occurrence of each element is kept. 5 | * The order of result values is determined by the order they occur in the array. 6 | * Can be passed an optional `identity` function to select the identifying part of objects. 7 | * 8 | * ```js 9 | * flocky.unique([1, 1, 2, 4, 2, 1, 6]) 10 | * // -> [1, 2, 4, 6] 11 | * 12 | * flocky.unique(['foo', 'bar', 'foo', 'foobar']) 13 | * // -> ['foo', 'bar', 'foobar'] 14 | * 15 | * const input = [{ id: 1, a: 1 }, { id: 1, a: 2 }, { id: 2, a: 3 }, { id: 1, a: 4 }] 16 | * flocky.unique(input, (element) => element.id) 17 | * // -> [{ id: 1, a: 1 }, { id: 2, a: 3 }] 18 | * ``` 19 | */ 20 | 21 | export function unique(array: Array, identity?: (x: T) => unknown): Array { 22 | if (!identity) { 23 | return primitiveUnique(array) 24 | } 25 | 26 | return objectUnique(array, identity) 27 | } 28 | 29 | function primitiveUnique(array: Array): Array { 30 | return Array.from(new Set(array)) 31 | } 32 | 33 | function objectUnique(array: Array, identity: (x: T) => unknown): Array { 34 | const identities = array.map((x) => identity(x)) 35 | return array.filter((_, i) => identities.indexOf(identities[i]) === i) 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Output Options 4 | "target": "es2022", 5 | "module": "esnext", 6 | "jsx": "react-jsx", 7 | "noEmitOnError": true, 8 | "newLine": "lf", 9 | "outDir": "dist/", 10 | "baseUrl": "./", 11 | "declaration": true, 12 | "sourceMap": true, 13 | "removeComments": false, 14 | "emitDecoratorMetadata": true, 15 | "experimentalDecorators": true, 16 | "incremental": true, 17 | 18 | // Type-Checking Options 19 | "lib": ["es2023", "dom"], 20 | "strict": true, 21 | "skipLibCheck": true, 22 | "strictPropertyInitialization": false, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | 26 | // Module Resolution Options 27 | "moduleResolution": "node", 28 | "allowSyntheticDefaultImports": true, 29 | "esModuleInterop": true, 30 | "forceConsistentCasingInFileNames": true, 31 | "resolveJsonModule": true, 32 | "allowJs": false 33 | }, 34 | "include": ["src/"] 35 | } 36 | --------------------------------------------------------------------------------