├── .browserslistrc ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── bundle-size-check.yml │ ├── test.yml │ └── validate.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── benchmarks ├── library-comparison.js └── shallow-equal.ts ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── are-inputs-equal.ts ├── memoize-one.js.flow └── memoize-one.ts ├── test ├── cache-clearing.spec.ts ├── memoize-one.spec.ts └── types-test.spec.ts ├── tsconfig.json └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | ie >= 11 2 | last 1 Edge version 3 | last 1 Firefox version 4 | last 1 Chrome version 5 | last 1 Safari version 6 | last 1 iOS version 7 | last 1 Android version 8 | last 1 ChromeAndroid version 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['prettier', 'plugin:@typescript-eslint/recommended', 'eslint:recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['prettier', 'jest', '@typescript-eslint'], 5 | env: { 6 | node: true, 7 | browser: true, 8 | es6: true, 9 | 'jest/globals': true, 10 | }, 11 | // custom rules 12 | rules: { 13 | // Error on prettier violations 14 | 'prettier/prettier': 'error', 15 | 16 | // New eslint style rules that is not disabled by prettier: 17 | 'lines-between-class-members': 'off', 18 | 19 | // Allowing warning and error console logging 20 | // use `invariant` and `warning` 21 | 'no-console': ['error'], 22 | 23 | // Opting out of prefer destructuring (nicer with types in lots of cases) 24 | 'prefer-destructuring': 'off', 25 | 26 | // Disallowing the use of variables starting with `_` unless it called on `this`. 27 | // Allowed: `this._secret = Symbol()` 28 | // Not allowed: `const _secret = Symbol()` 29 | 'no-underscore-dangle': ['error', { allowAfterThis: true }], 30 | 31 | // Cannot reassign function parameters but allowing modification 32 | 'no-param-reassign': ['error', { props: false }], 33 | 34 | // Allowing ++ on numbers 35 | 'no-plusplus': 'off', 36 | 37 | 'no-unused-vars': 'off', 38 | 39 | '@typescript-eslint/no-inferrable-types': 'off', 40 | '@typescript-eslint/ban-ts-ignore': 'off', 41 | '@typescript-eslint/no-explicit-any': 'off', 42 | '@typescript-eslint/ban-ts-comment': 'off', 43 | '@typescript-eslint/no-empty-function': 'off', 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for npm 4 | - package-ecosystem: "npm" 5 | # Look for `package.json` and `lock` files in the `root` directory 6 | directory: "/" 7 | # Always increase the version requirement to match the new version. 8 | versioning-strategy: increase 9 | # Check the npm registry for updates at the start of every week 10 | schedule: 11 | interval: "weekly" 12 | day: "monday" 13 | time: "08:00" 14 | timezone: "Australia/Sydney" -------------------------------------------------------------------------------- /.github/workflows/bundle-size-check.yml: -------------------------------------------------------------------------------- 1 | name: Check bundle size 2 | 3 | # This workflow only supported on pull requests 4 | on: pull_request 5 | 6 | jobs: 7 | # This workflow contains a single job called "size" 8 | size-limit: 9 | runs-on: ubuntu-latest 10 | env: 11 | CI_JOB_NUMBER: 1 12 | steps: 13 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: '16' 18 | 19 | # The size limit github action 20 | - uses: andresz1/size-limit-action@v1 21 | with: 22 | github_token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ['**/**'] 8 | 9 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 10 | jobs: 11 | jest: 12 | # The type of runner that the job will run on 13 | runs-on: ubuntu-latest 14 | 15 | # Steps represent a sequence of tasks that will be executed as part of the job 16 | steps: 17 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '16' 22 | 23 | - name: Restore dependency cache 24 | uses: actions/cache@v2 25 | with: 26 | path: '**/node_modules' 27 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 28 | 29 | - name: Install dependencies 30 | run: yarn install 31 | 32 | # Run tests 33 | - name: Tests 34 | run: yarn test 35 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Typescript, eslint, prettier checks 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ['**/**'] 8 | 9 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 10 | jobs: 11 | validate: 12 | # The type of runner that the job will run on 13 | runs-on: ubuntu-latest 14 | 15 | # Steps represent a sequence of tasks that will be executed as part of the job 16 | steps: 17 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: '16' 22 | 23 | - name: Restore dependency cache 24 | uses: actions/cache@v2 25 | with: 26 | path: '**/node_modules' 27 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 28 | 29 | - name: Install dependencies 30 | run: yarn install 31 | 32 | # Validates project 33 | - name: Typescript, eslint, prettier checks 34 | run: yarn validate 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea 3 | .vscode 4 | 5 | # library 6 | node_modules/ 7 | 8 | # MacOS 9 | .DS_Store 10 | 11 | # generated files 12 | dist/ 13 | 14 | # editors 15 | .idea 16 | .vscode 17 | 18 | # yard 19 | yarn-error.log -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.11.1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "semi": true, 4 | "tabWidth": 2, 5 | "useTabs": false, 6 | "singleQuote": true, 7 | "printWidth": 100 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Alexander Reardon 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # memoize-one 2 | 3 | A memoization library that only caches the result of the most recent arguments. 4 | 5 | [![npm](https://img.shields.io/npm/v/memoize-one.svg)](https://www.npmjs.com/package/memoize-one) 6 | ![types](https://img.shields.io/badge/types-typescript%20%7C%20flow-blueviolet) 7 | [![minzip](https://img.shields.io/bundlephobia/minzip/memoize-one.svg)](https://www.npmjs.com/package/memoize-one) 8 | [![Downloads per month](https://img.shields.io/npm/dm/memoize-one.svg)](https://www.npmjs.com/package/memoize-one) 9 | 10 | ## Rationale 11 | 12 | Unlike other memoization libraries, `memoize-one` only remembers the latest arguments and result. No need to worry about cache busting mechanisms such as `maxAge`, `maxSize`, `exclusions` and so on, which can be prone to memory leaks. A function memoized with `memoize-one` simply remembers the last arguments, and if the memoized function is next called with the same arguments then it returns the previous result. 13 | 14 | > For working with promises, [@Kikobeats](https://github.com/Kikobeats) has built [async-memoize-one](https://github.com/microlinkhq/async-memoize-one). 15 | 16 | ## Usage 17 | 18 | ```js 19 | // memoize-one uses the default import 20 | import memoizeOne from 'memoize-one'; 21 | 22 | function add(a, b) { 23 | return a + b; 24 | } 25 | const memoizedAdd = memoizeOne(add); 26 | 27 | memoizedAdd(1, 2); 28 | // add function: is called 29 | // [new value returned: 3] 30 | 31 | memoizedAdd(1, 2); 32 | // add function: not called 33 | // [cached result is returned: 3] 34 | 35 | memoizedAdd(2, 3); 36 | // add function: is called 37 | // [new value returned: 5] 38 | 39 | memoizedAdd(2, 3); 40 | // add function: not called 41 | // [cached result is returned: 5] 42 | 43 | memoizedAdd(1, 2); 44 | // add function: is called 45 | // [new value returned: 3] 46 | // 👇 47 | // While the result of `add(1, 2)` was previously cached 48 | // `(1, 2)` was not the *latest* arguments (the last call was `(2, 3)`) 49 | // so the previous cached result of `(1, 3)` was lost 50 | ``` 51 | 52 | ## Installation 53 | 54 | ```bash 55 | # yarn 56 | yarn add memoize-one 57 | 58 | # npm 59 | npm install memoize-one --save 60 | ``` 61 | 62 | ## Function argument equality 63 | 64 | By default, we apply our own _fast_ and _relatively naive_ equality function to determine whether the arguments provided to your function are equal. You can see the full code here: [are-inputs-equal.ts](https://github.com/alexreardon/memoize-one/blob/master/src/are-inputs-equal.ts). 65 | 66 | (By default) function arguments are considered equal if: 67 | 68 | 1. there is same amount of arguments 69 | 2. each new argument has strict equality (`===`) with the previous argument 70 | 3. **[special case]** if two arguments are not `===` and they are both `NaN` then the two arguments are treated as equal 71 | 72 | What this looks like in practice: 73 | 74 | ```js 75 | import memoizeOne from 'memoize-one'; 76 | 77 | // add all numbers provided to the function 78 | const add = (...args = []) => 79 | args.reduce((current, value) => { 80 | return current + value; 81 | }, 0); 82 | const memoizedAdd = memoizeOne(add); 83 | ``` 84 | 85 | > 1. there is same amount of arguments 86 | 87 | ```js 88 | memoizedAdd(1, 2); 89 | // the amount of arguments has changed, so add function is called 90 | memoizedAdd(1, 2, 3); 91 | ``` 92 | 93 | > 2. new arguments have strict equality (`===`) with the previous argument 94 | 95 | ```js 96 | memoizedAdd(1, 2); 97 | // each argument is `===` to the last argument, so cache is used 98 | memoizedAdd(1, 2); 99 | // second argument has changed, so add function is called again 100 | memoizedAdd(1, 3); 101 | // the first value is not `===` to the previous first value (1 !== 3) 102 | // so add function is called again 103 | memoizedAdd(3, 1); 104 | ``` 105 | 106 | > 3. **[special case]** if the arguments are not `===` and they are both `NaN` then the argument is treated as equal 107 | 108 | ```js 109 | memoizedAdd(NaN); 110 | // Even though NaN !== NaN these arguments are 111 | // treated as equal as they are both `NaN` 112 | memoizedAdd(NaN); 113 | ``` 114 | 115 | ## Custom equality function 116 | 117 | You can also pass in a custom function for checking the equality of two sets of arguments 118 | 119 | ```js 120 | const memoized = memoizeOne(fn, isEqual); 121 | ``` 122 | 123 | An equality function should return `true` if the arguments are equal. If `true` is returned then the wrapped function will not be called. 124 | 125 | **Tip**: A custom equality function needs to compare `Arrays`. The `newArgs` array will be a new reference every time so a simple `newArgs === lastArgs` will always return `false`. 126 | 127 | Equality functions are not called if the `this` context of the function has changed (see below). 128 | 129 | Here is an example that uses a [lodash.isEqual](https://lodash.com/docs/4.17.15#isEqual) deep equal equality check 130 | 131 | > `lodash.isequal` correctly handles deep comparing two arrays 132 | 133 | ```js 134 | import memoizeOne from 'memoize-one'; 135 | import isDeepEqual from 'lodash.isequal'; 136 | 137 | const identity = (x) => x; 138 | 139 | const shallowMemoized = memoizeOne(identity); 140 | const deepMemoized = memoizeOne(identity, isDeepEqual); 141 | 142 | const result1 = shallowMemoized({ foo: 'bar' }); 143 | const result2 = shallowMemoized({ foo: 'bar' }); 144 | 145 | result1 === result2; // false - different object reference 146 | 147 | const result3 = deepMemoized({ foo: 'bar' }); 148 | const result4 = deepMemoized({ foo: 'bar' }); 149 | 150 | result3 === result4; // true - arguments are deep equal 151 | ``` 152 | 153 | The equality function needs to conform to the `EqualityFn` `type`: 154 | 155 | ```ts 156 | // TFunc is the function being memoized 157 | type EqualityFn any> = ( 158 | newArgs: Parameters, 159 | lastArgs: Parameters, 160 | ) => boolean; 161 | 162 | // You can import this type 163 | import type { EqualityFn } from 'memoize-one'; 164 | ``` 165 | 166 | The `EqualityFn` type allows you to create equality functions that are extremely typesafe. You are welcome to provide your own less type safe equality functions. 167 | 168 | Here are some examples of equality functions which are ordered by most type safe, to least type safe: 169 | 170 |
171 | Example equality function types 172 |

173 | 174 | ```ts 175 | // the function we are going to memoize 176 | function add(first: number, second: number): number { 177 | return first + second; 178 | } 179 | 180 | // Some options for our equality function 181 | // ↑ stronger types 182 | // ↓ weaker types 183 | 184 | // ✅ exact parameters of `add` 185 | { 186 | const isEqual = function (first: Parameters, second: Parameters) { 187 | return true; 188 | }; 189 | expectTypeOf().toMatchTypeOf>(); 190 | } 191 | 192 | // ✅ tuple of the correct types 193 | { 194 | const isEqual = function (first: [number, number], second: [number, number]) { 195 | return true; 196 | }; 197 | expectTypeOf().toMatchTypeOf>(); 198 | } 199 | 200 | // ❌ tuple of incorrect types 201 | { 202 | const isEqual = function (first: [number, string], second: [number, number]) { 203 | return true; 204 | }; 205 | expectTypeOf().not.toMatchTypeOf>(); 206 | } 207 | 208 | // ✅ array of the correct types 209 | { 210 | const isEqual = function (first: number[], second: number[]) { 211 | return true; 212 | }; 213 | expectTypeOf().toMatchTypeOf>(); 214 | } 215 | 216 | // ❌ array of incorrect types 217 | { 218 | const isEqual = function (first: string[], second: number[]) { 219 | return true; 220 | }; 221 | expectTypeOf().not.toMatchTypeOf>(); 222 | } 223 | 224 | // ✅ tuple of 'unknown' 225 | { 226 | const isEqual = function (first: [unknown, unknown], second: [unknown, unknown]) { 227 | return true; 228 | }; 229 | expectTypeOf().toMatchTypeOf>(); 230 | } 231 | 232 | // ❌ tuple of 'unknown' of incorrect length 233 | { 234 | const isEqual = function (first: [unknown, unknown, unknown], second: [unknown, unknown]) { 235 | return true; 236 | }; 237 | expectTypeOf().not.toMatchTypeOf>(); 238 | } 239 | 240 | // ✅ array of 'unknown' 241 | { 242 | const isEqual = function (first: unknown[], second: unknown[]) { 243 | return true; 244 | }; 245 | expectTypeOf().toMatchTypeOf>(); 246 | } 247 | 248 | // ✅ spread of 'unknown' 249 | { 250 | const isEqual = function (...first: unknown[]) { 251 | return !!first; 252 | }; 253 | expectTypeOf().toMatchTypeOf>(); 254 | } 255 | 256 | // ✅ tuple of 'any' 257 | { 258 | const isEqual = function (first: [any, any], second: [any, any]) { 259 | return true; 260 | }; 261 | expectTypeOf().toMatchTypeOf>(); 262 | } 263 | 264 | // ❌ tuple of 'any' or incorrect size 265 | { 266 | const isEqual = function (first: [any, any, any], second: [any, any]) { 267 | return true; 268 | }; 269 | expectTypeOf().not.toMatchTypeOf>(); 270 | } 271 | 272 | // ✅ array of 'any' 273 | { 274 | const isEqual = function (first: any[], second: any[]) { 275 | return true; 276 | }; 277 | expectTypeOf().toMatchTypeOf>(); 278 | } 279 | 280 | // ✅ two arguments of type any 281 | { 282 | const isEqual = function (first: any, second: any) { 283 | return true; 284 | }; 285 | expectTypeOf().toMatchTypeOf>(); 286 | } 287 | 288 | // ✅ a single argument of type any 289 | { 290 | const isEqual = function (first: any) { 291 | return true; 292 | }; 293 | expectTypeOf().toMatchTypeOf>(); 294 | } 295 | 296 | // ✅ spread of any type 297 | { 298 | const isEqual = function (...first: any[]) { 299 | return true; 300 | }; 301 | expectTypeOf().toMatchTypeOf>(); 302 | } 303 | ``` 304 | 305 |

306 |
307 | 308 | ## `this` 309 | 310 | ### `memoize-one` correctly respects `this` control 311 | 312 | This library takes special care to maintain, and allow control over the the `this` context for **both** the original function being memoized as well as the returned memoized function. Both the original function and the memoized function's `this` context respect [all the `this` controlling techniques](https://github.com/getify/You-Dont-Know-JS/blob/master/this%20%26%20object%20prototypes/ch2.md): 313 | 314 | - new bindings (`new`) 315 | - explicit binding (`call`, `apply`, `bind`); 316 | - implicit binding (call site: `obj.foo()`); 317 | - default binding (`window` or `undefined` in `strict mode`); 318 | - fat arrow binding (binding to lexical `this`) 319 | - ignored this (pass `null` as `this` to explicit binding) 320 | 321 | ### Changes to `this` is considered an argument change 322 | 323 | Changes to the running context (`this`) of a function can result in the function returning a different value even though its arguments have stayed the same: 324 | 325 | ```js 326 | function getA() { 327 | return this.a; 328 | } 329 | 330 | const temp1 = { 331 | a: 20, 332 | }; 333 | const temp2 = { 334 | a: 30, 335 | }; 336 | 337 | getA.call(temp1); // 20 338 | getA.call(temp2); // 30 339 | ``` 340 | 341 | Therefore, in order to prevent against unexpected results, `memoize-one` takes into account the current execution context (`this`) of the memoized function. If `this` is different to the previous invocation then it is considered a change in argument. [further discussion](https://github.com/alexreardon/memoize-one/issues/3). 342 | 343 | Generally this will be of no impact if you are not explicity controlling the `this` context of functions you want to memoize with [explicit binding](https://github.com/getify/You-Dont-Know-JS/blob/master/this%20%26%20object%20prototypes/ch2.md#explicit-binding) or [implicit binding](https://github.com/getify/You-Dont-Know-JS/blob/master/this%20%26%20object%20prototypes/ch2.md#implicit-binding). `memoize-One` will detect when you are manipulating `this` and will then consider the `this` context as an argument. If `this` changes, it will re-execute the original function even if the arguments have not changed. 344 | 345 | ## Clearing the memoization cache 346 | 347 | A `.clear()` property is added to memoized functions to allow you to clear it's memoization cache. 348 | 349 | This is helpful if you want to: 350 | 351 | - Release memory 352 | - Allow the result function to be called again without having to change arguments 353 | 354 | ```ts 355 | import memoizeOne from 'memoize-one'; 356 | 357 | function add(a: number, b: number): number { 358 | return a + b; 359 | } 360 | 361 | const memoizedAdd = memoizeOne(add); 362 | 363 | // first call - not memoized 364 | const first = memoizedAdd(1, 2); 365 | 366 | // second call - cache hit (result function not called) 367 | const second = memoizedAdd(1, 2); 368 | 369 | // 👋 clearing memoization cache 370 | memoizedAdd.clear(); 371 | 372 | // third call - not memoized (cache was cleared) 373 | const third = memoizedAdd(1, 2); 374 | ``` 375 | 376 | ## When your result function `throw`s 377 | 378 | > There is no caching when your result function throws 379 | 380 | If your result function `throw`s then the memoized function will also throw. The throw will not break the memoized functions existing argument cache. It means the memoized function will pretend like it was never called with arguments that made it `throw`. 381 | 382 | ```js 383 | const canThrow = (name: string) => { 384 | console.log('called'); 385 | if (name === 'throw') { 386 | throw new Error(name); 387 | } 388 | return { name }; 389 | }; 390 | 391 | const memoized = memoizeOne(canThrow); 392 | 393 | const value1 = memoized('Alex'); 394 | // result function called: console.log => 'called' 395 | 396 | const value2 = memoized('Alex'); 397 | // result function not called (cache hit) 398 | 399 | console.log(value1 === value2); 400 | // console.log => true 401 | 402 | try { 403 | memoized('throw'); 404 | // console.log => 'called' 405 | } catch (e) { 406 | firstError = e; 407 | } 408 | 409 | try { 410 | memoized('throw'); 411 | // console.log => 'called' 412 | // the result function was called again even though it was called twice 413 | // with the 'throw' string 414 | } catch (e) { 415 | secondError = e; 416 | } 417 | 418 | console.log(firstError !== secondError); 419 | // console.log => true 420 | 421 | const value3 = memoized('Alex'); 422 | // result function not called as the original memoization cache has not been busted 423 | 424 | console.log(value1 === value3); 425 | // console.log => true 426 | ``` 427 | 428 | ## Function properties 429 | 430 | Functions memoized with `memoize-one` do not preserve any properties on the function object. 431 | 432 | > This behaviour is correctly reflected in the TypeScript types 433 | 434 | ```ts 435 | import memoizeOne from 'memoize-one'; 436 | 437 | function add(a, b) { 438 | return a + b; 439 | } 440 | add.hello = 'hi'; 441 | 442 | console.log(typeof add.hello); // string 443 | 444 | const memoized = memoizeOne(add); 445 | 446 | // hello property on the `add` was not preserved 447 | console.log(typeof memoized.hello); // undefined 448 | ``` 449 | 450 | > If you feel strongly that `memoize-one` _should_ preserve function properties, please raise an issue. This decision was made in order to keep `memoize-one` as light as possible. 451 | 452 | For _now_, the `.length` property of a function is not preserved on the memoized function 453 | 454 | ```ts 455 | import memoizeOne from 'memoize-one'; 456 | 457 | function add(a, b) { 458 | return a + b; 459 | } 460 | 461 | console.log(add.length); // 2 462 | 463 | const memoized = memoizeOne(add); 464 | 465 | console.log(memoized.length); // 0 466 | ``` 467 | 468 | There is no (great) way to correctly set the `.length` property of the memoized function while also supporting ie11. Once we [remove ie11 support](https://github.com/alexreardon/memoize-one/issues/125) then we plan on setting the `.length` property of the memoized function to match the original function 469 | 470 | [→ discussion](https://github.com/alexreardon/memoize-one/pull/124). 471 | 472 | ## Memoized function `type` 473 | 474 | The resulting function you get back from `memoize-one` has _almost_ the same `type` as the function that you are memoizing 475 | 476 | ```ts 477 | declare type MemoizedFn any> = { 478 | clear: () => void; 479 | (this: ThisParameterType, ...args: Parameters): ReturnType; 480 | }; 481 | ``` 482 | 483 | - the same call signature as the function being memoized 484 | - a `.clear()` function property added 485 | - other function object properties on `TFunc` as not carried over 486 | 487 | You are welcome to use the `MemoizedFn` generic directly from `memoize-one` if you like: 488 | 489 | ```ts 490 | import memoize, { MemoizedFn } from 'memoize-one'; 491 | import isDeepEqual from 'lodash.isequal'; 492 | import { expectTypeOf } from 'expect-type'; 493 | 494 | // Takes any function: TFunc, and returns a Memoized 495 | function withDeepEqual any>(fn: TFunc): MemoizedFn { 496 | return memoize(fn, isDeepEqual); 497 | } 498 | 499 | function add(first: number, second: number): number { 500 | return first + second; 501 | } 502 | 503 | const memoized = withDeepEqual(add); 504 | 505 | expectTypeOf().toEqualTypeOf>(); 506 | ``` 507 | 508 | In this specific example, this type would have been correctly inferred too 509 | 510 | ```ts 511 | import memoize, { MemoizedFn } from 'memoize-one'; 512 | import isDeepEqual from 'lodash.isequal'; 513 | import { expectTypeOf } from 'expect-type'; 514 | 515 | // return type of MemoizedFn is inferred 516 | function withDeepEqual any>(fn: TFunc) { 517 | return memoize(fn, isDeepEqual); 518 | } 519 | 520 | function add(first: number, second: number): number { 521 | return first + second; 522 | } 523 | 524 | const memoized = withDeepEqual(add); 525 | 526 | // type test still passes 527 | expectTypeOf().toEqualTypeOf>(); 528 | ``` 529 | 530 | ## Performance 🚀 531 | 532 | ### Tiny 533 | 534 | `memoize-one` is super lightweight at [![min](https://img.shields.io/bundlephobia/min/memoize-one.svg?label=)](https://www.npmjs.com/package/memoize-one) minified and [![minzip](https://img.shields.io/bundlephobia/minzip/memoize-one.svg?label=)](https://www.npmjs.com/package/memoize-one) gzipped. (`1KB` = `1,024 Bytes`) 535 | 536 | ### Extremely fast 537 | 538 | `memoize-one` performs better or on par with than other popular memoization libraries for the purpose of remembering the latest invocation. 539 | 540 | The comparisons are not exhaustive and are primarily to show that `memoize-one` accomplishes remembering the latest invocation really fast. There is variability between runs. The benchmarks do not take into account the differences in feature sets, library sizes, parse time, and so on. 541 | 542 |
543 | Expand for results 544 |

545 | 546 | node version `16.11.1` 547 | 548 | You can run this test in the repo by: 549 | 550 | 1. Add `"type": "module"` to the `package.json` (why is things so hard) 551 | 2. Run `yarn perf:library-comparison` 552 | 553 | **no arguments** 554 | 555 | | Position | Library | Operations per second | 556 | | -------- | -------------------------------------------- | --------------------- | 557 | | 1 | memoize-one | 80,112,981 | 558 | | 2 | moize | 72,885,631 | 559 | | 3 | memoizee | 35,550,009 | 560 | | 4 | mem (JSON.stringify strategy) | 4,610,532 | 561 | | 5 | lodash.memoize (JSON.stringify key resolver) | 3,708,945 | 562 | | 6 | no memoization | 505 | 563 | | 7 | fast-memoize | 504 | 564 | 565 | **single primitive argument** 566 | 567 | | Position | Library | Operations per second | 568 | | -------- | -------------------------------------------- | --------------------- | 569 | | 1 | fast-memoize | 45,482,711 | 570 | | 2 | moize | 34,810,659 | 571 | | 3 | memoize-one | 29,030,828 | 572 | | 4 | memoizee | 23,467,065 | 573 | | 5 | mem (JSON.stringify strategy) | 3,985,223 | 574 | | 6 | lodash.memoize (JSON.stringify key resolver) | 3,369,297 | 575 | | 7 | no memoization | 507 | 576 | 577 | **single complex argument** 578 | 579 | | Position | Library | Operations per second | 580 | | -------- | -------------------------------------------- | --------------------- | 581 | | 1 | moize | 27,660,856 | 582 | | 2 | memoize-one | 22,407,916 | 583 | | 3 | memoizee | 19,546,835 | 584 | | 4 | mem (JSON.stringify strategy) | 2,068,038 | 585 | | 5 | lodash.memoize (JSON.stringify key resolver) | 1,911,335 | 586 | | 6 | fast-memoize | 1,633,855 | 587 | | 7 | no memoization | 504 | 588 | 589 | **multiple primitive arguments** 590 | 591 | | Position | Library | Operations per second | 592 | | -------- | -------------------------------------------- | --------------------- | 593 | | 1 | moize | 22,366,497 | 594 | | 2 | memoize-one | 17,241,995 | 595 | | 3 | memoizee | 9,789,442 | 596 | | 4 | mem (JSON.stringify strategy) | 3,065,328 | 597 | | 5 | lodash.memoize (JSON.stringify key resolver) | 2,663,599 | 598 | | 6 | fast-memoize | 1,219,548 | 599 | | 7 | no memoization | 504 | 600 | 601 | **multiple complex arguments** 602 | 603 | | Position | Library | Operations per second | 604 | | -------- | -------------------------------------------- | --------------------- | 605 | | 1 | moize | 21,788,081 | 606 | | 2 | memoize-one | 17,321,248 | 607 | | 3 | memoizee | 9,595,420 | 608 | | 4 | lodash.memoize (JSON.stringify key resolver) | 873,283 | 609 | | 5 | mem (JSON.stringify strategy) | 850,779 | 610 | | 6 | fast-memoize | 687,863 | 611 | | 7 | no memoization | 504 | 612 | 613 | **multiple complex arguments (spreading arguments)** 614 | 615 | | Position | Library | Operations per second | 616 | | -------- | -------------------------------------------- | --------------------- | 617 | | 1 | moize | 21,701,537 | 618 | | 2 | memoizee | 19,463,942 | 619 | | 3 | memoize-one | 17,027,544 | 620 | | 4 | lodash.memoize (JSON.stringify key resolver) | 887,816 | 621 | | 5 | mem (JSON.stringify strategy) | 849,244 | 622 | | 6 | fast-memoize | 691,512 | 623 | | 7 | no memoization | 504 | 624 | 625 |

626 |
627 | 628 | ## Code health 👍 629 | 630 | - Tested with all built in [JavaScript types](https://github.com/getify/You-Dont-Know-JS/blob/1st-ed/types%20%26%20grammar/ch1.md) 631 | - Written in `Typescript` 632 | - Correct typing for `Typescript` and `flow` type systems 633 | - No dependencies 634 | -------------------------------------------------------------------------------- /benchmarks/library-comparison.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import Benchmark from 'benchmark'; 3 | import memoizeOne from '../dist/memoize-one.esm.js'; 4 | import lodash from 'lodash.memoize'; 5 | import fastMemoize from 'fast-memoize'; 6 | import mem from 'mem'; 7 | import ora from 'ora'; 8 | import moize from 'moize'; 9 | import memoizee from 'memoizee'; 10 | import { green, bold } from 'nanocolors'; 11 | import { markdownTable } from 'markdown-table'; 12 | 13 | const libraries = [ 14 | { 15 | name: 'no memoization', 16 | memoize: (fn) => fn, 17 | }, 18 | { 19 | name: 'memoize-one', 20 | memoize: memoizeOne, 21 | }, 22 | { 23 | name: 'lodash.memoize (JSON.stringify key resolver)', 24 | memoize: (fn) => { 25 | const resolver = (...args) => JSON.stringify(args); 26 | return lodash(fn, resolver); 27 | }, 28 | }, 29 | { 30 | name: 'fast-memoize', 31 | memoize: fastMemoize, 32 | }, 33 | { 34 | name: 'moize', 35 | memoize: moize, 36 | }, 37 | { 38 | name: 'memoizee', 39 | memoize: memoizee, 40 | }, 41 | { 42 | name: 'mem (JSON.stringify strategy)', 43 | // mem supports lots of strategies, choosing a 'fair' one for lots of operations 44 | memoize: (fn) => mem(fn, { cacheKey: JSON.stringify }), 45 | }, 46 | ]; 47 | 48 | function slowFn() { 49 | // Burn CPU for 2ms 50 | const start = Date.now(); 51 | while (Date.now() - start < 2) { 52 | void undefined; 53 | } 54 | } 55 | 56 | const scenarios = [ 57 | { 58 | name: 'no arguments', 59 | baseFn: slowFn, 60 | args: [], 61 | }, 62 | { 63 | name: 'single primitive argument', 64 | baseFn: function add1(value) { 65 | slowFn(); 66 | return value + 1; 67 | }, 68 | args: [2], 69 | }, 70 | { 71 | name: 'single complex argument', 72 | baseFn: function identity(value) { 73 | slowFn(); 74 | return value; 75 | }, 76 | args: [{ hello: 'world' }], 77 | }, 78 | { 79 | name: 'multiple primitive arguments', 80 | baseFn: function asArray(a, b, c) { 81 | slowFn(); 82 | return [a, b, c]; 83 | }, 84 | args: [1, 'hello', true], 85 | }, 86 | { 87 | name: 'multiple complex arguments', 88 | baseFn: function asArray(a, b, c) { 89 | slowFn(); 90 | return [a, b, c]; 91 | }, 92 | args: [() => {}, { hello: { there: 'world' } }, [1, 2, 3]], 93 | }, 94 | { 95 | name: 'multiple complex arguments (spreading arguments)', 96 | baseFn: function asArray(...values) { 97 | slowFn(); 98 | return [...values]; 99 | }, 100 | args: [() => {}, { hello: { there: 'world' } }, [1, 2, 3]], 101 | }, 102 | ]; 103 | 104 | scenarios.forEach((scenario) => { 105 | const suite = new Benchmark.Suite(scenario.name); 106 | 107 | libraries.forEach(function callback(library) { 108 | const memoized = library.memoize(scenario.baseFn); 109 | const spinner = ora({ 110 | text: library.name, 111 | spinner: { 112 | frames: ['⏳'], 113 | }, 114 | }); 115 | 116 | // Add a benchmark 117 | suite.add({ 118 | name: library.name, 119 | fn: () => memoized(...scenario.args), 120 | onStart: () => spinner.start(), 121 | onComplete: () => spinner.succeed(), 122 | }); 123 | }); 124 | 125 | suite.on('start', () => { 126 | console.log(`${bold('Scenario')}: ${green(scenario.name)}`); 127 | }); 128 | // suite.on('cycle', (e) => console.log(String(e.target))); 129 | suite.on('complete', (event) => { 130 | const benchmarks = Object.values(event.currentTarget).filter( 131 | (item) => item instanceof Benchmark, 132 | ); 133 | const rows = benchmarks 134 | // bigger score goes first 135 | .sort((a, b) => { 136 | return b.hz - a.hz; 137 | }) 138 | .map((benchmark, index) => { 139 | return [index + 1, benchmark.name, Math.round(benchmark.hz).toLocaleString()]; 140 | }); 141 | 142 | console.log('\nMarkdown:\n'); 143 | console.log(`**${scenario.name}**\n`); 144 | 145 | const table = markdownTable([['Position', 'Library', 'Operations per second'], ...rows]); 146 | console.log(table); 147 | console.log(''); 148 | }); 149 | suite.run(); 150 | }); 151 | -------------------------------------------------------------------------------- /benchmarks/shallow-equal.ts: -------------------------------------------------------------------------------- 1 | import benchmark from 'benchmark'; 2 | 3 | const suite = new benchmark.Suite(); 4 | 5 | import areInputsEqual from '../src/are-inputs-equal'; 6 | 7 | function shallowEvery(a: unknown[], b: unknown[]): boolean { 8 | if (a.length !== b.length) { 9 | return false; 10 | } 11 | 12 | return a.every((e, i) => b[i] === e); 13 | } 14 | 15 | function shallowFor(a: unknown[], b: unknown[]): boolean { 16 | if (a.length !== b.length) { 17 | return false; 18 | } 19 | 20 | for (let i = 0; i < a.length; i++) { 21 | if (a[i] !== b[i]) { 22 | return false; 23 | } 24 | } 25 | return true; 26 | } 27 | 28 | const a = {}; 29 | const b = {}; 30 | 31 | const listA = [a, b, {}, {}]; 32 | const listB = [a, b, {}, {}]; 33 | 34 | suite.add('shallowEvery with identical lists', () => { 35 | shallowEvery(listA, listA); 36 | }); 37 | 38 | suite.add('shallowFor with identical lists', () => { 39 | shallowFor(listA, listA); 40 | }); 41 | 42 | suite.add('shallowEvery with half-identical lists', () => { 43 | shallowEvery(listA, listB); 44 | }); 45 | 46 | suite.add('shallowFor with half-identical lists', () => { 47 | shallowFor(listA, listB); 48 | }); 49 | 50 | suite.add('our areInputsEqual with identical lists', () => { 51 | areInputsEqual(listA, listA); 52 | }); 53 | 54 | suite.add('our areInputsEqual with half-identical lists', () => { 55 | areInputsEqual(listA, listB); 56 | }); 57 | 58 | // eslint-disable-next-line no-console 59 | suite.on('cycle', (e: any) => console.log(String(e.target))); 60 | 61 | suite.run({ async: true }); 62 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | 5 | globals: { 6 | 'ts-jest': { 7 | diagnostics: false, 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "memoize-one", 3 | "version": "6.0.0", 4 | "description": "A memoization library which only remembers the latest invocation", 5 | "author": "Alex Reardon ", 6 | "license": "MIT", 7 | "keywords": [ 8 | "memoize", 9 | "memoization", 10 | "cache", 11 | "performance" 12 | ], 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/alexreardon/memoize-one.git" 16 | }, 17 | "bugs": { 18 | "url": "https://github.com/alexreardon/memoize-one/issues" 19 | }, 20 | "main": "dist/memoize-one.cjs.js", 21 | "types": "dist/memoize-one.d.ts", 22 | "module": "dist/memoize-one.esm.js", 23 | "sideEffects": false, 24 | "files": [ 25 | "/dist", 26 | "/src" 27 | ], 28 | "size-limit": [ 29 | { 30 | "path": "dist/memoize-one.min.js", 31 | "limit": "234B" 32 | }, 33 | { 34 | "path": "dist/memoize-one.js", 35 | "limit": "234B" 36 | }, 37 | { 38 | "path": "dist/memoize-one.cjs.js", 39 | "limit": "230B" 40 | }, 41 | { 42 | "path": "dist/memoize-one.esm.js", 43 | "limit": "246B" 44 | } 45 | ], 46 | "dependencies": {}, 47 | "devDependencies": { 48 | "@rollup/plugin-replace": "^3.0.0", 49 | "@rollup/plugin-typescript": "^8.3.0", 50 | "@size-limit/preset-small-lib": "^5.0.4", 51 | "@types/benchmark": "^2.1.1", 52 | "@types/jest": "^27.0.2", 53 | "@types/lodash.isequal": "^4.5.5", 54 | "@types/lodash.memoize": "^4.1.6", 55 | "@types/node": "^16.10.1", 56 | "@typescript-eslint/eslint-plugin": "^4.31.2", 57 | "@typescript-eslint/parser": "^4.31.2", 58 | "benchmark": "^2.1.4", 59 | "cross-env": "^7.0.3", 60 | "eslint": "7.32.0", 61 | "eslint-config-prettier": "^8.3.0", 62 | "eslint-plugin-jest": "^24.4.2", 63 | "eslint-plugin-prettier": "^4.0.0", 64 | "expect-type": "^0.12.0", 65 | "fast-memoize": "^2.5.2", 66 | "jest": "^27.2.2", 67 | "lodash.isequal": "^4.5.0", 68 | "lodash.memoize": "^4.1.2", 69 | "markdown-table": "^3.0.1", 70 | "mem": "^9.0.1", 71 | "memoizee": "^0.4.15", 72 | "moize": "^6.1.0", 73 | "nanocolors": "^0.2.9", 74 | "ora": "^6.0.1", 75 | "prettier": "2.4.1", 76 | "rimraf": "3.0.2", 77 | "rollup": "^2.57.0", 78 | "rollup-plugin-terser": "^7.0.2", 79 | "size-limit": "^5.0.4", 80 | "ts-jest": "^27.0.5", 81 | "ts-node": "^10.2.1", 82 | "tslib": "^2.3.1", 83 | "typescript": "^4.4.3" 84 | }, 85 | "config": { 86 | "prettier_target": "src/**/*.{ts,js,jsx,md,json} test/**/*.{ts,js,jsx,md,json}" 87 | }, 88 | "scripts": { 89 | "validate": "yarn prettier:check && yarn eslint:check && yarn typescript:check", 90 | "test": "yarn jest", 91 | "test:size": "yarn build && yarn size-limit", 92 | "typescript:check": "yarn tsc --noEmit", 93 | "prettier:check": "yarn prettier --debug-check $npm_package_config_prettier_target", 94 | "prettier:write": "yarn prettier --write $npm_package_config_prettier_target", 95 | "eslint:check": "eslint $npm_package_config_prettier_target", 96 | "build": "yarn build:clean && yarn build:dist && yarn build:typescript && yarn build:flow", 97 | "build:clean": "yarn rimraf dist", 98 | "build:dist": "yarn rollup -c", 99 | "build:typescript": "yarn tsc ./src/memoize-one.ts --emitDeclarationOnly --declaration --outDir ./dist", 100 | "build:flow": "cp src/memoize-one.js.flow dist/memoize-one.cjs.js.flow", 101 | "perf": "yarn ts-node ./benchmarks/shallow-equal.ts", 102 | "perf:library-comparison": "yarn build && node ./benchmarks/library-comparison.js", 103 | "prepublishOnly": "yarn build" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import typescript from '@rollup/plugin-typescript'; 3 | import replace from '@rollup/plugin-replace'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | 6 | const input = 'src/memoize-one.ts'; 7 | 8 | export default [ 9 | // Universal module definition (UMD) build 10 | { 11 | input, 12 | output: { 13 | file: 'dist/memoize-one.js', 14 | format: 'umd', 15 | name: 'memoizeOne', 16 | }, 17 | plugins: [ 18 | // Setting development env before running other steps 19 | replace({ 'process.env.NODE_ENV': JSON.stringify('development'), preventAssignment: true }), 20 | typescript({ module: 'ESNext' }), 21 | ], 22 | }, 23 | // Universal module definition (UMD) build (production) 24 | { 25 | input, 26 | output: { 27 | file: 'dist/memoize-one.min.js', 28 | format: 'umd', 29 | name: 'memoizeOne', 30 | }, 31 | plugins: [ 32 | // Setting production env before running other steps 33 | replace({ 'process.env.NODE_ENV': JSON.stringify('production'), preventAssignment: true }), 34 | typescript({ module: 'ESNext' }), 35 | terser(), 36 | ], 37 | }, 38 | // ESM build 39 | { 40 | input, 41 | output: { 42 | file: 'dist/memoize-one.esm.js', 43 | format: 'esm', 44 | }, 45 | plugins: [typescript({ module: 'ESNext' })], 46 | }, 47 | // CommonJS build 48 | { 49 | input, 50 | output: { 51 | file: 'dist/memoize-one.cjs.js', 52 | format: 'cjs', 53 | exports: 'default', 54 | }, 55 | plugins: [typescript({ module: 'ESNext' })], 56 | }, 57 | ]; 58 | -------------------------------------------------------------------------------- /src/are-inputs-equal.ts: -------------------------------------------------------------------------------- 1 | // Number.isNaN as it is not supported in IE11 so conditionally using ponyfill 2 | // Using Number.isNaN where possible as it is ~10% faster 3 | 4 | const safeIsNaN = 5 | Number.isNaN || 6 | function ponyfill(value: unknown): boolean { 7 | // // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN#polyfill 8 | // NaN is the only value in JavaScript which is not equal to itself. 9 | return typeof value === 'number' && value !== value; 10 | }; 11 | 12 | function isEqual(first: unknown, second: unknown): boolean { 13 | if (first === second) { 14 | return true; 15 | } 16 | 17 | // Special case for NaN (NaN !== NaN) 18 | if (safeIsNaN(first) && safeIsNaN(second)) { 19 | return true; 20 | } 21 | 22 | return false; 23 | } 24 | 25 | export default function areInputsEqual( 26 | newInputs: readonly unknown[], 27 | lastInputs: readonly unknown[], 28 | ): boolean { 29 | // no checks needed if the inputs length has changed 30 | if (newInputs.length !== lastInputs.length) { 31 | return false; 32 | } 33 | // Using for loop for speed. It generally performs better than array.every 34 | // https://github.com/alexreardon/memoize-one/pull/59 35 | for (let i = 0; i < newInputs.length; i++) { 36 | if (!isEqual(newInputs[i], lastInputs[i])) { 37 | return false; 38 | } 39 | } 40 | return true; 41 | } 42 | -------------------------------------------------------------------------------- /src/memoize-one.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | // These types are not as powerful as the TypeScript types, but they get the job done 4 | 5 | export type EqualityFn = (newArgs: mixed[], lastArgs: mixed[]) => boolean; 6 | 7 | // default export 8 | declare export default function memoizeOne mixed>( 9 | fn: ResultFn, 10 | isEqual?: EqualityFn, 11 | ): ResultFn & { clear: () => void }; 12 | -------------------------------------------------------------------------------- /src/memoize-one.ts: -------------------------------------------------------------------------------- 1 | import areInputsEqual from './are-inputs-equal'; 2 | 3 | export type EqualityFn any> = ( 4 | newArgs: Parameters, 5 | lastArgs: Parameters, 6 | ) => boolean; 7 | 8 | export type MemoizedFn any> = { 9 | clear: () => void; 10 | (this: ThisParameterType, ...args: Parameters): ReturnType; 11 | }; 12 | 13 | // internal type 14 | type Cache any> = { 15 | lastThis: ThisParameterType; 16 | lastArgs: Parameters; 17 | lastResult: ReturnType; 18 | }; 19 | 20 | function memoizeOne any>( 21 | resultFn: TFunc, 22 | isEqual: EqualityFn = areInputsEqual, 23 | ): MemoizedFn { 24 | let cache: Cache | null = null; 25 | 26 | // breaking cache when context (this) or arguments change 27 | function memoized( 28 | this: ThisParameterType, 29 | ...newArgs: Parameters 30 | ): ReturnType { 31 | if (cache && cache.lastThis === this && isEqual(newArgs, cache.lastArgs)) { 32 | return cache.lastResult; 33 | } 34 | 35 | // Throwing during an assignment aborts the assignment: https://codepen.io/alexreardon/pen/RYKoaz 36 | // Doing the lastResult assignment first so that if it throws 37 | // the cache will not be overwritten 38 | const lastResult = resultFn.apply(this, newArgs); 39 | cache = { 40 | lastResult, 41 | lastArgs: newArgs, 42 | lastThis: this, 43 | }; 44 | 45 | return lastResult; 46 | } 47 | 48 | // Adding the ability to clear the cache of a memoized function 49 | memoized.clear = function clear() { 50 | cache = null; 51 | }; 52 | 53 | return memoized; 54 | } 55 | 56 | export default memoizeOne; 57 | -------------------------------------------------------------------------------- /test/cache-clearing.spec.ts: -------------------------------------------------------------------------------- 1 | import memoize from '../src/memoize-one'; 2 | 3 | it('should enable cache clearing', () => { 4 | const underlyingFn = jest.fn(function add(a: number, b: number) { 5 | return a + b; 6 | }); 7 | 8 | const memoizedAdd = memoize(underlyingFn); 9 | 10 | // first call - not memoized 11 | const first = memoizedAdd(1, 2); 12 | expect(first).toBe(3); 13 | expect(underlyingFn).toHaveBeenCalledTimes(1); 14 | 15 | // second call - memoized (underlying function not called) 16 | const second = memoizedAdd(1, 2); 17 | expect(second).toBe(3); 18 | expect(underlyingFn).toHaveBeenCalledTimes(1); 19 | 20 | // clearing memoization cache 21 | memoizedAdd.clear(); 22 | 23 | // third call - not memoized (cache was cleared) 24 | const third = memoizedAdd(1, 2); 25 | expect(third).toBe(3); 26 | expect(underlyingFn).toHaveBeenCalledTimes(2); 27 | }); 28 | -------------------------------------------------------------------------------- /test/memoize-one.spec.ts: -------------------------------------------------------------------------------- 1 | import memoize, { EqualityFn } from '../src/memoize-one'; 2 | import isDeepEqual from 'lodash.isequal'; 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | type HasA = { 6 | a: number; 7 | }; 8 | 9 | function getA(this: HasA | null | undefined): number { 10 | if (this == null) { 11 | throw new TypeError(); 12 | } 13 | return this.a; 14 | } 15 | 16 | type AddFn = (value1: number, value2: number) => number; 17 | 18 | describe('standard behaviour - baseline', () => { 19 | let add: AddFn; 20 | let memoizedAdd: AddFn; 21 | 22 | beforeEach(() => { 23 | add = jest.fn().mockImplementation((value1: number, value2: number): number => value1 + value2); 24 | memoizedAdd = memoize(add); 25 | }); 26 | 27 | it('should return the result of a function', () => { 28 | expect(memoizedAdd(1, 2)).toBe(3); 29 | }); 30 | 31 | it('should return the same result if the arguments have not changed', () => { 32 | expect(memoizedAdd(1, 2)).toBe(3); 33 | expect(memoizedAdd(1, 2)).toBe(3); 34 | }); 35 | 36 | it('should not execute the memoized function if the arguments have not changed', () => { 37 | memoizedAdd(1, 2); 38 | memoizedAdd(1, 2); 39 | 40 | expect(add).toHaveBeenCalledTimes(1); 41 | }); 42 | 43 | it('should invalidate a memoize cache if new arguments are provided', () => { 44 | expect(memoizedAdd(1, 2)).toBe(3); 45 | expect(memoizedAdd(2, 2)).toBe(4); 46 | expect(add).toHaveBeenCalledTimes(2); 47 | }); 48 | 49 | it('should resume memoization after a cache invalidation', () => { 50 | expect(memoizedAdd(1, 2)).toBe(3); 51 | expect(add).toHaveBeenCalledTimes(1); 52 | expect(memoizedAdd(2, 2)).toBe(4); 53 | expect(add).toHaveBeenCalledTimes(2); 54 | expect(memoizedAdd(2, 2)).toBe(4); 55 | expect(add).toHaveBeenCalledTimes(2); 56 | }); 57 | }); 58 | 59 | describe('standard behaviour - dynamic', () => { 60 | type Expectation = { 61 | args: unknown[]; 62 | result: unknown; 63 | }; 64 | 65 | type Input = { 66 | name: string; 67 | first: Expectation; 68 | second: Expectation; 69 | }; 70 | 71 | // [JavaScript defines seven built-in types:](https://github.com/getify/You-Dont-Know-JS/blob/master/types%20%26%20grammar/ch1.md) 72 | // - null 73 | // - undefined 74 | // - boolean 75 | // - number 76 | // - string 77 | // - object 78 | // - symbol 79 | const inputs: Input[] = [ 80 | { 81 | name: 'null', 82 | first: { 83 | args: [null, null], 84 | result: true, 85 | }, 86 | second: { 87 | args: [null], 88 | result: false, 89 | }, 90 | }, 91 | { 92 | name: 'undefined', 93 | first: { 94 | args: [], 95 | result: true, 96 | }, 97 | second: { 98 | args: [undefined, undefined], 99 | result: false, 100 | }, 101 | }, 102 | { 103 | name: 'boolean', 104 | first: { 105 | args: [true, false], 106 | result: true, 107 | }, 108 | second: { 109 | args: [false, true], 110 | result: false, 111 | }, 112 | }, 113 | { 114 | name: 'number', 115 | first: { 116 | args: [1, 2], 117 | result: 3, 118 | }, 119 | second: { 120 | args: [1, 5], 121 | result: 6, 122 | }, 123 | }, 124 | { 125 | name: 'number (NaN special case)', 126 | first: { 127 | args: [NaN, 2], 128 | result: 3, 129 | }, 130 | second: { 131 | args: [NaN, 5], 132 | result: 6, 133 | }, 134 | }, 135 | { 136 | name: 'string', 137 | first: { 138 | args: ['hi', 'there'], 139 | result: 'greetings', 140 | }, 141 | second: { 142 | args: ['luke', 'skywalker'], 143 | result: 'starwars', 144 | }, 145 | }, 146 | 147 | { 148 | name: 'object: different values and references', 149 | first: { 150 | args: [{ foo: 'bar' }], 151 | result: { baz: 'bar' }, 152 | }, 153 | second: { 154 | args: [{ bar: 'test' }], 155 | result: { baz: true }, 156 | }, 157 | }, 158 | { 159 | name: 'object: same values but different references', 160 | first: { 161 | args: [{ foo: 'bar' }], 162 | result: { baz: 'bar' }, 163 | }, 164 | second: { 165 | args: [{ foo: 'bar' }], 166 | result: { baz: 'bar' }, 167 | }, 168 | }, 169 | { 170 | name: 'symbols', 171 | first: { 172 | args: [Symbol('first')], 173 | result: true, 174 | }, 175 | second: { 176 | args: [Symbol('second')], 177 | result: false, 178 | }, 179 | }, 180 | ]; 181 | 182 | const isShallowEqual = (array1: Array, array2: Array): boolean => { 183 | if (array1 === array2) { 184 | return true; 185 | } 186 | 187 | return ( 188 | array1.length === array2.length && 189 | array1.every((item, i) => { 190 | if (array2[i] === item) { 191 | return true; 192 | } 193 | if (Number.isNaN(array2[i]) && Number.isNaN(item)) { 194 | return true; 195 | } 196 | return false; 197 | }) 198 | ); 199 | }; 200 | 201 | inputs.forEach(({ name, first, second }) => { 202 | describe(`type: [${name}]`, () => { 203 | let mock: (...args: any[]) => unknown; 204 | let memoized: (...args: any[]) => unknown; 205 | 206 | beforeEach(() => { 207 | mock = jest.fn().mockImplementation((...args) => { 208 | if (isShallowEqual(args, first.args)) { 209 | return first.result; 210 | } 211 | if (isShallowEqual(args, second.args)) { 212 | return second.result; 213 | } 214 | throw new Error('unmatched argument'); 215 | }); 216 | 217 | memoized = memoize(mock); 218 | }); 219 | 220 | it('should return the result of a function', () => { 221 | expect(memoized(...first.args)).toEqual(first.result); 222 | }); 223 | 224 | it('should return the same result if the arguments have not changed', () => { 225 | expect(memoized(...first.args)).toEqual(first.result); 226 | expect(memoized(...first.args)).toEqual(first.result); 227 | }); 228 | 229 | it('should not execute the memoized function if the arguments have not changed', () => { 230 | memoized(...first.args); 231 | memoized(...first.args); 232 | 233 | expect(mock).toHaveBeenCalledTimes(1); 234 | }); 235 | 236 | it('should invalidate a memoize cache if new arguments are provided', () => { 237 | expect(memoized(...first.args)).toEqual(first.result); 238 | expect(memoized(...second.args)).toEqual(second.result); 239 | expect(mock).toHaveBeenCalledTimes(2); 240 | }); 241 | 242 | it('should resume memoization after a cache invalidation', () => { 243 | expect(memoized(...first.args)).toEqual(first.result); 244 | expect(mock).toHaveBeenCalledTimes(1); 245 | expect(memoized(...second.args)).toEqual(second.result); 246 | expect(mock).toHaveBeenCalledTimes(2); 247 | expect(memoized(...second.args)).toEqual(second.result); 248 | expect(mock).toHaveBeenCalledTimes(2); 249 | }); 250 | }); 251 | }); 252 | }); 253 | 254 | describe('respecting "this" context', () => { 255 | describe('original function', () => { 256 | it('should respect new bindings', () => { 257 | type HasBar = { 258 | bar: string; 259 | }; 260 | const Foo = function (this: HasBar, bar: string): void { 261 | this.bar = bar; 262 | }; 263 | const memoized = memoize(function (bar) { 264 | // @ts-ignore 265 | return new Foo(bar); 266 | }); 267 | 268 | const result = memoized('baz'); 269 | 270 | expect(result instanceof Foo).toBe(true); 271 | expect(result.bar).toBe('baz'); 272 | }); 273 | 274 | it('should respect explicit bindings', () => { 275 | const temp = { 276 | a: 10, 277 | }; 278 | 279 | const memoized = memoize(function () { 280 | return getA.call(temp); 281 | }); 282 | 283 | expect(memoized()).toBe(10); 284 | }); 285 | 286 | it('should respect hard bindings', () => { 287 | const temp = { 288 | a: 20, 289 | }; 290 | 291 | const memoized = memoize(getA.bind(temp)); 292 | 293 | expect(memoized()).toBe(20); 294 | }); 295 | 296 | it('should respect implicit bindings', () => { 297 | const temp = { 298 | a: 2, 299 | getA, 300 | }; 301 | 302 | const memoized = memoize(function () { 303 | return temp.getA(); 304 | }); 305 | 306 | expect(memoized()).toBe(2); 307 | }); 308 | 309 | it('should respect fat arrow bindings', () => { 310 | const temp = { 311 | a: 50, 312 | }; 313 | function foo() { 314 | // return an arrow function 315 | return (): number => { 316 | // `this` here is lexically adopted from `foo()` 317 | // @ts-ignore 318 | return getA.call(this); 319 | }; 320 | } 321 | const bound = foo.call(temp); 322 | const memoized = memoize(bound); 323 | 324 | expect(memoized()).toBe(50); 325 | }); 326 | 327 | it('should respect ignored bindings', () => { 328 | const bound = getA.bind(null); 329 | const memoized = memoize(bound); 330 | 331 | expect(memoized).toThrow(TypeError); 332 | }); 333 | }); 334 | 335 | describe('memoized function', () => { 336 | it('should respect new bindings', () => { 337 | const memoizedGetA = memoize(getA); 338 | interface FooInterface { 339 | a: number; 340 | result: number; 341 | } 342 | 343 | const Foo = function (this: FooInterface, a: number): void { 344 | this.a = a; 345 | this.result = memoizedGetA.call(this); 346 | }; 347 | 348 | // @ts-ignore 349 | const foo1 = new Foo(10); 350 | // @ts-ignore 351 | const foo2 = new Foo(20); 352 | 353 | expect(foo1.result).toBe(10); 354 | expect(foo2.result).toBe(20); 355 | }); 356 | 357 | it('should respect implicit bindings', () => { 358 | const getAMemoized = memoize(getA); 359 | const temp = { 360 | a: 5, 361 | getAMemoized, 362 | }; 363 | 364 | expect(temp.getAMemoized()).toBe(5); 365 | }); 366 | 367 | it('should respect explicit bindings', () => { 368 | const temp = { 369 | a: 10, 370 | }; 371 | 372 | const memoized = memoize(getA); 373 | 374 | expect(memoized.call(temp)).toBe(10); 375 | }); 376 | 377 | it('should respect hard bindings', () => { 378 | const temp = { 379 | a: 20, 380 | }; 381 | 382 | const getAMemoized = memoize(getA).bind(temp); 383 | 384 | expect(getAMemoized()).toBe(20); 385 | }); 386 | 387 | it('should memoize hard bound memoized functions', () => { 388 | const temp = { 389 | a: 40, 390 | }; 391 | const spy = jest.fn().mockImplementation(getA); 392 | 393 | const getAMemoized = memoize(spy).bind(temp); 394 | 395 | expect(getAMemoized()).toBe(40); 396 | expect(getAMemoized()).toBe(40); 397 | expect(spy).toHaveBeenCalledTimes(1); 398 | }); 399 | 400 | it('should respect implicit bindings', () => { 401 | const getAMemoized = memoize(getA); 402 | const temp = { 403 | a: 2, 404 | getAMemoized, 405 | }; 406 | 407 | expect(temp.getAMemoized()).toBe(2); 408 | }); 409 | 410 | it('should respect fat arrow bindings', () => { 411 | const temp: HasA = { 412 | a: 50, 413 | }; 414 | const memoizedGetA = memoize(getA); 415 | function foo() { 416 | // return an arrow function 417 | return (): number => { 418 | // `this` here is lexically adopted from `foo()` 419 | // @ts-ignore 420 | return memoizedGetA.call(this); 421 | }; 422 | } 423 | const bound = foo.call(temp); 424 | const memoized = memoize(bound); 425 | 426 | expect(memoized()).toBe(50); 427 | }); 428 | 429 | it('should respect ignored bindings', () => { 430 | const memoized = memoize(getA); 431 | 432 | const getResult = function (): number { 433 | return memoized.call(null); 434 | }; 435 | 436 | expect(getResult).toThrow(TypeError); 437 | }); 438 | }); 439 | }); 440 | 441 | describe('context change', () => { 442 | it('should break the memoization cache if the execution context changes', () => { 443 | const memoized = memoize(getA); 444 | const temp1 = { 445 | a: 20, 446 | getMemoizedA: memoized, 447 | }; 448 | const temp2 = { 449 | a: 30, 450 | getMemoizedA: memoized, 451 | }; 452 | 453 | expect(temp1.getMemoizedA()).toBe(20); 454 | expect(temp2.getMemoizedA()).toBe(30); 455 | }); 456 | }); 457 | 458 | describe('skip equality check', () => { 459 | it('should not run any equality checks if the "this" context changes', () => { 460 | const isEqual = jest.fn().mockReturnValue(true); 461 | const memoized = memoize(getA, isEqual); 462 | const obj1: HasA = { 463 | a: 10, 464 | }; 465 | const obj2: HasA = { 466 | a: 20, 467 | }; 468 | 469 | // using explicit binding change 470 | 471 | // custom equality function not called on first call 472 | expect(memoized.apply(obj1)).toBe(10); 473 | expect(isEqual).not.toHaveBeenCalled(); 474 | 475 | // not executed as "this" context has changed 476 | expect(memoized.apply(obj2)).toBe(20); 477 | expect(isEqual).not.toHaveBeenCalled(); 478 | }); 479 | 480 | it('should run a custom equality check if the arguments length changes', () => { 481 | const mock = jest.fn(); 482 | const isEqual = jest.fn().mockReturnValue(true); 483 | const memoized = memoize(mock, isEqual); 484 | 485 | memoized(1, 2); 486 | // not executed on original call 487 | expect(isEqual).not.toHaveBeenCalled(); 488 | 489 | // executed even though argument length has changed 490 | memoized(1, 2, 3); 491 | expect(isEqual).toHaveBeenCalled(); 492 | }); 493 | }); 494 | 495 | describe('custom equality function', () => { 496 | let add: AddFn; 497 | let memoizedAdd: AddFn; 498 | let equalityStub: EqualityFn; 499 | 500 | beforeEach(() => { 501 | add = jest.fn().mockImplementation((value1: number, value2: number): number => value1 + value2); 502 | equalityStub = jest.fn(); 503 | memoizedAdd = memoize(add, equalityStub); 504 | }); 505 | 506 | it('should call the equality function with the newArgs, lastArgs and lastValue', () => { 507 | (equalityStub as jest.Mock).mockReturnValue(true); 508 | 509 | // first call does not trigger equality check 510 | memoizedAdd(1, 2); 511 | expect(equalityStub).not.toHaveBeenCalled(); 512 | memoizedAdd(1, 4); 513 | 514 | expect(equalityStub).toHaveBeenCalledWith([1, 4], [1, 2]); 515 | }); 516 | 517 | it('should have a nice isDeepEqual consumption story', () => { 518 | type Person = { 519 | age: number; 520 | }; 521 | const clone = (person: Person): Person => JSON.parse(JSON.stringify(person)); 522 | const bob: Person = { 523 | age: 10, 524 | }; 525 | const jane: Person = { 526 | age: 2, 527 | }; 528 | const tim: Person = { 529 | age: 1, 530 | }; 531 | 532 | const addAges = jest 533 | .fn() 534 | .mockImplementation((...people: Person[]): number => 535 | people.reduce((sum: number, person: Person) => sum + person.age, 0), 536 | ); 537 | const memoized = memoize(addAges, isDeepEqual); 538 | 539 | // addAges executed on first call 540 | expect(memoized(clone(bob), clone(jane))).toBe(12); 541 | expect(addAges).toHaveBeenCalled(); 542 | addAges.mockClear(); 543 | 544 | // memoized function not called 545 | expect(memoized(clone(bob), clone(jane))).toBe(12); 546 | expect(addAges).not.toHaveBeenCalled(); 547 | 548 | // memoized function called (we lodash happily handled argument change) 549 | expect(memoized(clone(bob), clone(jane), clone(tim))).toBe(13); 550 | expect(addAges).toHaveBeenCalled(); 551 | }); 552 | 553 | it('should return the previous value without executing the result fn if the equality fn returns true', () => { 554 | (equalityStub as jest.Mock).mockReturnValue(true); 555 | 556 | // hydrate the first value 557 | const first: number = memoizedAdd(1, 2); 558 | expect(first).toBe(3); 559 | expect(add).toHaveBeenCalledTimes(1); 560 | 561 | // equality test should not be called yet 562 | expect(equalityStub).not.toHaveBeenCalled(); 563 | 564 | // normally would return false - but our custom equality function returns true 565 | const second = memoizedAdd(4, 10); 566 | 567 | expect(second).toBe(3); 568 | // equality test occured 569 | expect(equalityStub).toHaveBeenCalled(); 570 | // underlying function not called 571 | expect(add).toHaveBeenCalledTimes(1); 572 | }); 573 | 574 | it('should return execute and return the result of the result fn if the equality fn returns false', () => { 575 | (equalityStub as jest.Mock).mockReturnValue(false); 576 | 577 | // hydrate the first value 578 | const first: number = memoizedAdd(1, 2); 579 | expect(first).toBe(3); 580 | expect(add).toHaveBeenCalledTimes(1); 581 | 582 | // equality test should not be called yet 583 | expect(equalityStub).not.toHaveBeenCalled(); 584 | 585 | const second = memoizedAdd(4, 10); 586 | 587 | expect(second).toBe(14); 588 | // equality test occured 589 | expect(equalityStub).toHaveBeenCalled(); 590 | // underlying function called 591 | expect(add).toHaveBeenCalledTimes(2); 592 | }); 593 | }); 594 | 595 | describe('throwing', () => { 596 | it('should throw when the memoized function throws', () => { 597 | const willThrow = (message: string): never => { 598 | throw new Error(message); 599 | }; 600 | const memoized = memoize(willThrow); 601 | 602 | expect(memoized).toThrow(); 603 | }); 604 | 605 | it('should not memoize a thrown result', () => { 606 | const willThrow = jest.fn().mockImplementation((message: string) => { 607 | throw new Error(message); 608 | }); 609 | const memoized = memoize(willThrow); 610 | let firstError; 611 | let secondError; 612 | 613 | try { 614 | memoized('hello'); 615 | } catch (e) { 616 | firstError = e; 617 | } 618 | 619 | try { 620 | memoized('hello'); 621 | } catch (e) { 622 | secondError = e; 623 | } 624 | 625 | expect(willThrow).toHaveBeenCalledTimes(2); 626 | expect(firstError).toEqual(Error('hello')); 627 | expect(firstError).not.toBe(secondError); 628 | }); 629 | 630 | it('should not break the memoization cache of a successful call', () => { 631 | const canThrow = jest.fn().mockImplementation((shouldThrow: boolean) => { 632 | if (shouldThrow) { 633 | throw new Error('hey friend'); 634 | } 635 | // will return a new object reference each time 636 | return { hello: 'world' }; 637 | }); 638 | const memoized = memoize(canThrow); 639 | let firstError; 640 | let secondError; 641 | 642 | // standard memoization 643 | const result1 = memoized(false); 644 | const result2 = memoized(false); 645 | expect(result1).toBe(result2); 646 | expect(canThrow).toHaveBeenCalledTimes(1); 647 | canThrow.mockClear(); 648 | 649 | // a call that throws 650 | try { 651 | memoized(true); 652 | } catch (e) { 653 | firstError = e; 654 | } 655 | 656 | expect(canThrow).toHaveBeenCalledTimes(1); 657 | canThrow.mockClear(); 658 | 659 | // call with last successful arguments has not had its cache busted 660 | const result3 = memoized(false); 661 | expect(canThrow).not.toHaveBeenCalled(); 662 | expect(result3).toBe(result2); 663 | canThrow.mockClear(); 664 | 665 | // now going to throw again 666 | try { 667 | memoized(true); 668 | } catch (e) { 669 | secondError = e; 670 | } 671 | 672 | // underlying function is called 673 | expect(canThrow).toHaveBeenCalledTimes(1); 674 | expect(firstError).toEqual(secondError); 675 | expect(firstError).not.toBe(secondError); 676 | canThrow.mockClear(); 677 | 678 | // last successful cache value is not lost and result fn not called 679 | const result4 = memoized(false); 680 | expect(canThrow).not.toHaveBeenCalled(); 681 | expect(result4).toBe(result3); 682 | }); 683 | 684 | it('should throw regardless of the type of the thrown value', () => { 685 | // [JavaScript defines seven built-in types:](https://github.com/getify/You-Dont-Know-JS/blob/master/types%20%26%20grammar/ch1.md) 686 | // - null 687 | // - undefined 688 | // - boolean 689 | // - number 690 | // - string 691 | // - object 692 | // - symbol 693 | const values: unknown[] = [ 694 | null, 695 | undefined, 696 | true, 697 | false, 698 | 10, 699 | 'hi', 700 | { name: 'Alex' }, 701 | Symbol('sup'), 702 | ]; 703 | 704 | values.forEach((value: unknown) => { 705 | const throwValue = jest.fn().mockImplementation(() => { 706 | throw value; 707 | }); 708 | const memoized = memoize(throwValue); 709 | let firstError; 710 | let secondError; 711 | 712 | try { 713 | memoized(); 714 | } catch (e) { 715 | firstError = e; 716 | } 717 | 718 | try { 719 | memoized(); 720 | } catch (e) { 721 | secondError = e; 722 | } 723 | 724 | // no memoization 725 | expect(firstError).toEqual(value); 726 | 727 | // validation - no memoization 728 | expect(throwValue).toHaveBeenCalledTimes(2); 729 | expect(firstError).toEqual(secondError); 730 | }); 731 | }); 732 | }); 733 | 734 | describe('typing', () => { 735 | it('should maintain the type of the original function', () => { 736 | // this test will create a type error if the typing is incorrect 737 | type SubtractFn = (a: number, b: number) => number; 738 | const subtract: SubtractFn = (a: number, b: number): number => a - b; 739 | const requiresASubtractFn = (fn: SubtractFn): number => fn(2, 1); 740 | 741 | const memoizedSubtract: SubtractFn = memoize(subtract); 742 | 743 | const result1 = requiresASubtractFn(memoizedSubtract); 744 | const result2 = requiresASubtractFn(memoize(subtract)); 745 | 746 | expect(result1).toBe(1); 747 | expect(result2).toBe(1); 748 | }); 749 | 750 | it('should support typed equality functions', () => { 751 | const subtract = (a: number, b: number): number => a - b; 752 | 753 | const valid = [ 754 | (newArgs: readonly number[], lastArgs: readonly number[]): boolean => 755 | JSON.stringify(newArgs) === JSON.stringify(lastArgs), 756 | (): boolean => true, 757 | (value: unknown[]): boolean => value.length === 5, 758 | ]; 759 | 760 | valid.forEach((isEqual) => { 761 | const memoized = memoize(subtract, isEqual); 762 | expect(memoized(3, 1)).toBe(2); 763 | }); 764 | }); 765 | }); 766 | -------------------------------------------------------------------------------- /test/types-test.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import isDeepEqual from 'lodash.isequal'; 3 | import type { EqualityFn, MemoizedFn } from './../src/memoize-one'; 4 | import { expectTypeOf } from 'expect-type'; 5 | import memoize from '../src/memoize-one'; 6 | 7 | it('should maintain the types of the original function', () => { 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | function getLocation(this: Window, value: number) { 10 | return this.location; 11 | } 12 | const memoized = memoize(getLocation); 13 | 14 | expectTypeOf>().toEqualTypeOf< 15 | ThisParameterType 16 | >(); 17 | expectTypeOf>().toEqualTypeOf>(); 18 | expectTypeOf>().toEqualTypeOf>(); 19 | }); 20 | 21 | it('should add a .clear function property', () => { 22 | function add(first: number, second: number) { 23 | return first + second; 24 | } 25 | const memoized = memoize(add); 26 | memoized.clear(); 27 | 28 | // @ts-expect-error 29 | expect(() => memoized.foo()).toThrow(); 30 | 31 | expectTypeOf().toEqualTypeOf<() => void>(); 32 | }); 33 | 34 | it('should return a `MemoizedFn`', () => { 35 | function add(first: number, second: number): number { 36 | return first + second; 37 | } 38 | 39 | const memoized = memoize(add); 40 | 41 | expectTypeOf().toEqualTypeOf>(); 42 | }); 43 | 44 | it('should allow you to leverage the MemoizedFn generic to allow many memoized functions', () => { 45 | function withDeepEqual any>(fn: TFunc): MemoizedFn { 46 | return memoize(fn, isDeepEqual); 47 | } 48 | function add(first: number, second: number): number { 49 | return first + second; 50 | } 51 | 52 | const memoized = withDeepEqual(add); 53 | 54 | expectTypeOf().toEqualTypeOf>(); 55 | }); 56 | 57 | it('should return a memoized function that satisies a typeof check for the original function', () => { 58 | function add(first: number, second: number): number { 59 | return first + second; 60 | } 61 | function caller(fn: typeof add) { 62 | return fn(1, 2); 63 | } 64 | const memoized = memoize(add); 65 | 66 | // this line is the actual type test 67 | const result = caller(memoized); 68 | expectTypeOf().toEqualTypeOf(); 69 | }); 70 | 71 | it('should allow casting back to the original function type', () => { 72 | type AddFn = (first: number, second: number) => number; 73 | function add(first: number, second: number): number { 74 | return first + second; 75 | } 76 | // baseline 77 | { 78 | const memoized = memoize(add); 79 | expectTypeOf().toEqualTypeOf(); 80 | expectTypeOf().toEqualTypeOf>(); 81 | expectTypeOf().toEqualTypeOf>(); 82 | expectTypeOf().toMatchTypeOf>(); 83 | expectTypeOf().toMatchTypeOf>(); 84 | } 85 | { 86 | const memoized: typeof add = memoize(add); 87 | expectTypeOf().toEqualTypeOf(); 88 | expectTypeOf().not.toMatchTypeOf>(); 89 | expectTypeOf().not.toMatchTypeOf>(); 90 | } 91 | { 92 | const memoized: AddFn = memoize(add); 93 | expectTypeOf().toEqualTypeOf(); 94 | expectTypeOf().not.toMatchTypeOf>(); 95 | expectTypeOf().not.toMatchTypeOf>(); 96 | } 97 | { 98 | const memoized = memoize(add) as typeof add; 99 | expectTypeOf().toEqualTypeOf(); 100 | expectTypeOf().not.toMatchTypeOf>(); 101 | expectTypeOf().not.toMatchTypeOf>(); 102 | } 103 | { 104 | const memoized = memoize(add) as AddFn; 105 | expectTypeOf().toEqualTypeOf(); 106 | expectTypeOf().not.toMatchTypeOf>(); 107 | expectTypeOf().not.toMatchTypeOf>(); 108 | } 109 | }); 110 | 111 | it('should type the equality function to based on the provided function', () => { 112 | function add(first: number, second: number) { 113 | return first + second; 114 | } 115 | 116 | expectTypeOf>().toEqualTypeOf< 117 | (newArgs: Parameters, lastArgs: Parameters) => boolean 118 | >(); 119 | expectTypeOf>().toEqualTypeOf< 120 | (newArgs: [number, number], lastArgs: [number, number]) => boolean 121 | >(); 122 | }); 123 | 124 | it('should allow weaker equality function types', () => { 125 | function add(first: number, second: number): number { 126 | return first + second; 127 | } 128 | 129 | // ✅ exact parameters of `add` 130 | { 131 | const isEqual = function (first: Parameters, second: Parameters) { 132 | return true; 133 | }; 134 | expectTypeOf().toMatchTypeOf>(); 135 | } 136 | 137 | // ✅ tuple of the correct types 138 | { 139 | const isEqual = function (first: [number, number], second: [number, number]) { 140 | return true; 141 | }; 142 | expectTypeOf().toMatchTypeOf>(); 143 | } 144 | 145 | // ❌ tuple of incorrect types 146 | { 147 | const isEqual = function (first: [number, string], second: [number, number]) { 148 | return true; 149 | }; 150 | expectTypeOf().not.toMatchTypeOf>(); 151 | } 152 | 153 | // ✅ array of the correct types 154 | { 155 | const isEqual = function (first: number[], second: number[]) { 156 | return true; 157 | }; 158 | expectTypeOf().toMatchTypeOf>(); 159 | } 160 | 161 | // ❌ array of incorrect types 162 | { 163 | const isEqual = function (first: string[], second: number[]) { 164 | return true; 165 | }; 166 | expectTypeOf().not.toMatchTypeOf>(); 167 | } 168 | 169 | // ✅ tuple of 'unknown' 170 | { 171 | const isEqual = function (first: [unknown, unknown], second: [unknown, unknown]) { 172 | return true; 173 | }; 174 | expectTypeOf().toMatchTypeOf>(); 175 | } 176 | 177 | // ❌ tuple of 'unknown' of incorrect length 178 | { 179 | const isEqual = function (first: [unknown, unknown, unknown], second: [unknown, unknown]) { 180 | return true; 181 | }; 182 | expectTypeOf().not.toMatchTypeOf>(); 183 | } 184 | 185 | // ✅ array of 'unknown' 186 | { 187 | const isEqual = function (first: unknown[], second: unknown[]) { 188 | return true; 189 | }; 190 | expectTypeOf().toMatchTypeOf>(); 191 | } 192 | 193 | // ✅ spread of 'unknown' 194 | { 195 | const isEqual = function (...first: unknown[]) { 196 | return !!first; 197 | }; 198 | expectTypeOf().toMatchTypeOf>(); 199 | } 200 | 201 | // ✅ tuple of 'any' 202 | { 203 | const isEqual = function (first: [any, any], second: [any, any]) { 204 | return true; 205 | }; 206 | expectTypeOf().toMatchTypeOf>(); 207 | } 208 | 209 | // ❌ tuple of 'any' or incorrect size 210 | { 211 | const isEqual = function (first: [any, any, any], second: [any, any]) { 212 | return true; 213 | }; 214 | expectTypeOf().not.toMatchTypeOf>(); 215 | } 216 | 217 | // ✅ array of 'any' 218 | { 219 | const isEqual = function (first: any[], second: any[]) { 220 | return true; 221 | }; 222 | expectTypeOf().toMatchTypeOf>(); 223 | } 224 | 225 | // ✅ two arguments of type any 226 | { 227 | const isEqual = function (first: any, second: any) { 228 | return true; 229 | }; 230 | expectTypeOf().toMatchTypeOf>(); 231 | } 232 | 233 | // ✅ a single argument of type any 234 | { 235 | const isEqual = function (first: any) { 236 | return true; 237 | }; 238 | expectTypeOf().toMatchTypeOf>(); 239 | } 240 | 241 | // ✅ spread of any type 242 | { 243 | const isEqual = function (...first: any[]) { 244 | return true; 245 | }; 246 | expectTypeOf().toMatchTypeOf>(); 247 | } 248 | }); 249 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "target": "es5", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "removeComments": true, 11 | "esModuleInterop": true 12 | }, 13 | "include": ["src/", "test/"] 14 | } 15 | --------------------------------------------------------------------------------