├── .editorconfig ├── .gitattributes ├── .github ├── funding.yml ├── security.md └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.ts ├── license ├── package.json ├── readme.md ├── test-d └── index.test-d.ts ├── test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: [sindresorhus, fregante] 2 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | #- 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | distribution 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import mimicFunction from 'mimic-function'; 2 | 3 | type AnyFunction = (...arguments_: readonly any[]) => unknown; 4 | 5 | const cacheStore = new WeakMap>(); 6 | const cacheTimerStore = new WeakMap>(); 7 | 8 | type CacheStorageContent = { 9 | data: ValueType; 10 | maxAge: number; 11 | }; 12 | 13 | type CacheStorage = { 14 | has: (key: KeyType) => boolean; 15 | get: (key: KeyType) => CacheStorageContent | undefined; 16 | set: (key: KeyType, value: CacheStorageContent) => void; 17 | delete: (key: KeyType) => void; 18 | clear?: () => void; 19 | }; 20 | 21 | export type Options< 22 | FunctionToMemoize extends AnyFunction, 23 | CacheKeyType, 24 | > = { 25 | /** 26 | Milliseconds until the cache entry expires. 27 | 28 | If a function is provided, it receives the arguments and must return the max age. 29 | 30 | @default Infinity 31 | */ 32 | readonly maxAge?: number | ((...arguments_: Parameters) => number); 33 | 34 | /** 35 | Determines the cache key for storing the result based on the function arguments. By default, __only the first argument is considered__ and it only works with [primitives](https://developer.mozilla.org/en-US/docs/Glossary/Primitive). 36 | 37 | A `cacheKey` function can return any type supported by `Map` (or whatever structure you use in the `cache` option). 38 | 39 | You can have it cache **all** the arguments by value with `JSON.stringify`, if they are compatible: 40 | 41 | ``` 42 | import memoize from 'memoize'; 43 | 44 | memoize(function_, {cacheKey: JSON.stringify}); 45 | ``` 46 | 47 | Or you can use a more full-featured serializer like [serialize-javascript](https://github.com/yahoo/serialize-javascript) to add support for `RegExp`, `Date` and so on. 48 | 49 | ``` 50 | import memoize from 'memoize'; 51 | import serializeJavascript from 'serialize-javascript'; 52 | 53 | memoize(function_, {cacheKey: serializeJavascript}); 54 | ``` 55 | 56 | @default arguments_ => arguments_[0] 57 | @example arguments_ => JSON.stringify(arguments_) 58 | */ 59 | readonly cacheKey?: (arguments_: Parameters) => CacheKeyType; 60 | 61 | /** 62 | Use a different cache storage. Must implement the following methods: `.has(key)`, `.get(key)`, `.set(key, value)`, `.delete(key)`, and optionally `.clear()`. You could for example use a `WeakMap` instead or [`quick-lru`](https://github.com/sindresorhus/quick-lru) for a LRU cache. 63 | 64 | @default new Map() 65 | @example new WeakMap() 66 | */ 67 | readonly cache?: CacheStorage>; 68 | }; 69 | 70 | /** 71 | [Memoize](https://en.wikipedia.org/wiki/Memoization) functions - An optimization used to speed up consecutive function calls by caching the result of calls with identical input. 72 | 73 | @param function_ - The function to be memoized. 74 | 75 | @example 76 | ``` 77 | import memoize from 'memoize'; 78 | 79 | let index = 0; 80 | const counter = () => ++index; 81 | const memoized = memoize(counter); 82 | 83 | memoized('foo'); 84 | //=> 1 85 | 86 | // Cached as it's the same argument 87 | memoized('foo'); 88 | //=> 1 89 | 90 | // Not cached anymore as the arguments changed 91 | memoized('bar'); 92 | //=> 2 93 | 94 | memoized('bar'); 95 | //=> 2 96 | ``` 97 | */ 98 | export default function memoize< 99 | FunctionToMemoize extends AnyFunction, 100 | CacheKeyType, 101 | >( 102 | function_: FunctionToMemoize, 103 | { 104 | cacheKey, 105 | cache = new Map(), 106 | maxAge, 107 | }: Options = {}, 108 | ): FunctionToMemoize { 109 | if (maxAge === 0) { 110 | return function_; 111 | } 112 | 113 | if (typeof maxAge === 'number') { 114 | const maxSetIntervalValue = 2_147_483_647; 115 | if (maxAge > maxSetIntervalValue) { 116 | throw new TypeError(`The \`maxAge\` option cannot exceed ${maxSetIntervalValue}.`); 117 | } 118 | 119 | if (maxAge < 0) { 120 | throw new TypeError('The `maxAge` option should not be a negative number.'); 121 | } 122 | } 123 | 124 | const memoized = function (this: any, ...arguments_: Parameters): ReturnType { 125 | const key = cacheKey ? cacheKey(arguments_) : arguments_[0] as CacheKeyType; 126 | 127 | const cacheItem = cache.get(key); 128 | if (cacheItem) { 129 | return cacheItem.data; 130 | } 131 | 132 | const result = function_.apply(this, arguments_) as ReturnType; 133 | 134 | const computedMaxAge = typeof maxAge === 'function' ? maxAge(...arguments_) : maxAge; 135 | 136 | cache.set(key, { 137 | data: result, 138 | maxAge: computedMaxAge ? Date.now() + computedMaxAge : Number.POSITIVE_INFINITY, 139 | }); 140 | 141 | if (computedMaxAge && computedMaxAge > 0 && computedMaxAge !== Number.POSITIVE_INFINITY) { 142 | const timer = setTimeout(() => { 143 | cache.delete(key); 144 | }, computedMaxAge); 145 | 146 | timer.unref?.(); 147 | 148 | const timers = cacheTimerStore.get(function_) ?? new Set(); 149 | timers.add(timer as unknown as number); 150 | cacheTimerStore.set(function_, timers); 151 | } 152 | 153 | return result; 154 | } as FunctionToMemoize; 155 | 156 | mimicFunction(memoized, function_, { 157 | ignoreNonConfigurable: true, 158 | }); 159 | 160 | cacheStore.set(memoized, cache); 161 | 162 | return memoized; 163 | } 164 | 165 | /** 166 | @returns A [decorator](https://github.com/tc39/proposal-decorators) to memoize class methods or static class methods. 167 | 168 | @example 169 | ``` 170 | import {memoizeDecorator} from 'memoize'; 171 | 172 | class Example { 173 | index = 0 174 | 175 | @memoizeDecorator() 176 | counter() { 177 | return ++this.index; 178 | } 179 | } 180 | 181 | class ExampleWithOptions { 182 | index = 0 183 | 184 | @memoizeDecorator({maxAge: 1000}) 185 | counter() { 186 | return ++this.index; 187 | } 188 | } 189 | ``` 190 | */ 191 | export function memoizeDecorator< 192 | FunctionToMemoize extends AnyFunction, 193 | CacheKeyType, 194 | >( 195 | options: Options = {}, 196 | ) { 197 | const instanceMap = new WeakMap(); 198 | 199 | return ( 200 | target: any, 201 | propertyKey: string, 202 | descriptor: PropertyDescriptor, 203 | ): void => { 204 | const input = target[propertyKey]; // eslint-disable-line @typescript-eslint/no-unsafe-assignment 205 | 206 | if (typeof input !== 'function') { 207 | throw new TypeError('The decorated value must be a function'); 208 | } 209 | 210 | delete descriptor.value; 211 | delete descriptor.writable; 212 | 213 | descriptor.get = function () { 214 | if (!instanceMap.has(this)) { 215 | const value = memoize(input, options) as FunctionToMemoize; 216 | instanceMap.set(this, value); 217 | return value; 218 | } 219 | 220 | return instanceMap.get(this) as FunctionToMemoize; 221 | }; 222 | }; 223 | } 224 | 225 | /** 226 | Clear all cached data of a memoized function. 227 | 228 | @param function_ - The memoized function. 229 | */ 230 | export function memoizeClear(function_: AnyFunction): void { 231 | const cache = cacheStore.get(function_); 232 | if (!cache) { 233 | throw new TypeError('Can\'t clear a function that was not memoized!'); 234 | } 235 | 236 | if (typeof cache.clear !== 'function') { 237 | throw new TypeError('The cache Map can\'t be cleared!'); 238 | } 239 | 240 | cache.clear(); 241 | 242 | for (const timer of cacheTimerStore.get(function_) ?? []) { 243 | clearTimeout(timer); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "memoize", 3 | "version": "10.1.0", 4 | "description": "Memoize functions - An optimization used to speed up consecutive function calls by caching the result of calls with identical input", 5 | "license": "MIT", 6 | "repository": "sindresorhus/memoize", 7 | "funding": "https://github.com/sindresorhus/memoize?sponsor=1", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./distribution/index.d.ts", 16 | "default": "./distribution/index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=18" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava && npm run build && tsd --typings distribution/index.d.ts", 24 | "build": "del-cli distribution && tsc", 25 | "prepack": "npm run build" 26 | }, 27 | "files": [ 28 | "distribution" 29 | ], 30 | "keywords": [ 31 | "memoize", 32 | "function", 33 | "mem", 34 | "memoization", 35 | "cache", 36 | "caching", 37 | "optimize", 38 | "performance", 39 | "ttl", 40 | "expire", 41 | "promise" 42 | ], 43 | "dependencies": { 44 | "mimic-function": "^5.0.1" 45 | }, 46 | "devDependencies": { 47 | "@sindresorhus/tsconfig": "^6.0.0", 48 | "@types/serialize-javascript": "^5.0.4", 49 | "ava": "^6.1.3", 50 | "del-cli": "^5.1.0", 51 | "delay": "^6.0.0", 52 | "serialize-javascript": "^6.0.2", 53 | "ts-node": "^10.9.2", 54 | "tsd": "^0.31.1", 55 | "xo": "^0.59.3" 56 | }, 57 | "ava": { 58 | "timeout": "1m", 59 | "extensions": { 60 | "ts": "module" 61 | }, 62 | "nodeArguments": [ 63 | "--loader=ts-node/esm" 64 | ], 65 | "workerThreads": false 66 | }, 67 | "xo": { 68 | "rules": { 69 | "@typescript-eslint/no-unsafe-return": "off" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # memoize 2 | 3 | > [Memoize](https://en.wikipedia.org/wiki/Memoization) functions - An optimization used to speed up consecutive function calls by caching the result of calls with identical input 4 | 5 | Memory is automatically released when an item expires or the cache is cleared. 6 | 7 | 8 | 9 | By default, **only the memoized function's first argument is considered** via strict equality comparison. If you need to cache multiple arguments or cache `object`s *by value*, have a look at alternative [caching strategies](#caching-strategy) below. 10 | 11 | If you want to memoize Promise-returning functions (like `async` functions), you might be better served by [p-memoize](https://github.com/sindresorhus/p-memoize). 12 | 13 | ## Install 14 | 15 | ```sh 16 | npm install memoize 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```js 22 | import memoize from 'memoize'; 23 | 24 | let index = 0; 25 | const counter = () => ++index; 26 | const memoized = memoize(counter); 27 | 28 | memoized('foo'); 29 | //=> 1 30 | 31 | // Cached as it's the same argument 32 | memoized('foo'); 33 | //=> 1 34 | 35 | // Not cached anymore as the argument changed 36 | memoized('bar'); 37 | //=> 2 38 | 39 | memoized('bar'); 40 | //=> 2 41 | 42 | // Only the first argument is considered by default 43 | memoized('bar', 'foo'); 44 | //=> 2 45 | ``` 46 | 47 | ##### Works well with Promise-returning functions 48 | 49 | But you might want to use [p-memoize](https://github.com/sindresorhus/p-memoize) for more Promise-specific behaviors. 50 | 51 | ```js 52 | import memoize from 'memoize'; 53 | 54 | let index = 0; 55 | const counter = async () => ++index; 56 | const memoized = memoize(counter); 57 | 58 | console.log(await memoized()); 59 | //=> 1 60 | 61 | // The return value didn't increase as it's cached 62 | console.log(await memoized()); 63 | //=> 1 64 | ``` 65 | 66 | ```js 67 | import memoize from 'memoize'; 68 | import got from 'got'; 69 | import delay from 'delay'; 70 | 71 | const memoizedGot = memoize(got, {maxAge: 1000}); 72 | 73 | await memoizedGot('https://sindresorhus.com'); 74 | 75 | // This call is cached 76 | await memoizedGot('https://sindresorhus.com'); 77 | 78 | await delay(2000); 79 | 80 | // This call is not cached as the cache has expired 81 | await memoizedGot('https://sindresorhus.com'); 82 | ``` 83 | 84 | ### Caching strategy 85 | 86 | By default, only the first argument is compared via exact equality (`===`) to determine whether a call is identical. 87 | 88 | ```js 89 | import memoize from 'memoize'; 90 | 91 | const pow = memoize((a, b) => Math.pow(a, b)); 92 | 93 | pow(2, 2); // => 4, stored in cache with the key 2 (number) 94 | pow(2, 3); // => 4, retrieved from cache at key 2 (number), it's wrong 95 | ``` 96 | 97 | You will have to use the `cache` and `cacheKey` options appropriate to your function. In this specific case, the following could work: 98 | 99 | ```js 100 | import memoize from 'memoize'; 101 | 102 | const pow = memoize((a, b) => Math.pow(a, b), { 103 | cacheKey: arguments_ => arguments_.join(',') 104 | }); 105 | 106 | pow(2, 2); // => 4, stored in cache with the key '2,2' (both arguments as one string) 107 | pow(2, 3); // => 8, stored in cache with the key '2,3' 108 | ``` 109 | 110 | More advanced examples follow. 111 | 112 | #### Example: Options-like argument 113 | 114 | If your function accepts an object, it won't be memoized out of the box: 115 | 116 | ```js 117 | import memoize from 'memoize'; 118 | 119 | const heavyMemoizedOperation = memoize(heavyOperation); 120 | 121 | heavyMemoizedOperation({full: true}); // Stored in cache with the object as key 122 | heavyMemoizedOperation({full: true}); // Stored in cache with the object as key, again 123 | // The objects appear the same, but in JavaScript, they're different objects 124 | ``` 125 | 126 | You might want to serialize or hash them, for example using `JSON.stringify` or something like [serialize-javascript](https://github.com/yahoo/serialize-javascript), which can also serialize `RegExp`, `Date` and so on. 127 | 128 | ```js 129 | import memoize from 'memoize'; 130 | 131 | const heavyMemoizedOperation = memoize(heavyOperation, {cacheKey: JSON.stringify}); 132 | 133 | heavyMemoizedOperation({full: true}); // Stored in cache with the key '[{"full":true}]' (string) 134 | heavyMemoizedOperation({full: true}); // Retrieved from cache 135 | ``` 136 | 137 | The same solution also works if it accepts multiple serializable objects: 138 | 139 | ```js 140 | import memoize from 'memoize'; 141 | 142 | const heavyMemoizedOperation = memoize(heavyOperation, {cacheKey: JSON.stringify}); 143 | 144 | heavyMemoizedOperation('hello', {full: true}); // Stored in cache with the key '["hello",{"full":true}]' (string) 145 | heavyMemoizedOperation('hello', {full: true}); // Retrieved from cache 146 | ``` 147 | 148 | #### Example: Multiple non-serializable arguments 149 | 150 | If your function accepts multiple arguments that aren't supported by `JSON.stringify` (e.g. DOM elements and functions), you can instead extend the initial exact equality (`===`) to work on multiple arguments using [`many-keys-map`](https://github.com/fregante/many-keys-map): 151 | 152 | ```js 153 | import memoize from 'memoize'; 154 | import ManyKeysMap from 'many-keys-map'; 155 | 156 | const addListener = (emitter, eventName, listener) => emitter.on(eventName, listener); 157 | 158 | const addOneListener = memoize(addListener, { 159 | cacheKey: arguments_ => arguments_, // Use *all* the arguments as key 160 | cache: new ManyKeysMap() // Correctly handles all the arguments for exact equality 161 | }); 162 | 163 | addOneListener(header, 'click', console.log); // `addListener` is run, and it's cached with the `arguments` array as key 164 | addOneListener(header, 'click', console.log); // `addListener` is not run again because the arguments are the same 165 | addOneListener(mainContent, 'load', console.log); // `addListener` is run, and it's cached with the `arguments` array as key 166 | ``` 167 | 168 | Better yet, if your function’s arguments are compatible with `WeakMap`, you should use [`deep-weak-map`](https://github.com/futpib/deep-weak-map) instead of `many-keys-map`. This will help avoid memory leaks. 169 | 170 | ## API 171 | 172 | ### memoize(fn, options?) 173 | 174 | #### fn 175 | 176 | Type: `Function` 177 | 178 | The function to be memoized. 179 | 180 | #### options 181 | 182 | Type: `object` 183 | 184 | ##### maxAge 185 | 186 | Type: `number` | `Function`\ 187 | Default: `Infinity`\ 188 | Example: `arguments_ => arguments_ < new Date() ? Infinity : 60_000` 189 | 190 | Milliseconds until the cache entry expires. 191 | 192 | If a function is provided, it receives the arguments and must return the max age. 193 | 194 | ##### cacheKey 195 | 196 | Type: `Function`\ 197 | Default: `arguments_ => arguments_[0]`\ 198 | Example: `arguments_ => JSON.stringify(arguments_)` 199 | 200 | Determines the cache key for storing the result based on the function arguments. By default, **only the first argument is considered**. 201 | 202 | A `cacheKey` function can return any type supported by `Map` (or whatever structure you use in the `cache` option). 203 | 204 | Refer to the [caching strategies](#caching-strategy) section for more information. 205 | 206 | ##### cache 207 | 208 | Type: `object`\ 209 | Default: `new Map()` 210 | 211 | Use a different cache storage. Must implement the following methods: `.has(key)`, `.get(key)`, `.set(key, value)`, `.delete(key)`, and optionally `.clear()`. You could for example use a `WeakMap` instead or [`quick-lru`](https://github.com/sindresorhus/quick-lru) for a LRU cache. 212 | 213 | Refer to the [caching strategies](#caching-strategy) section for more information. 214 | 215 | ### memoizeDecorator(options) 216 | 217 | Returns a [decorator](https://github.com/tc39/proposal-decorators) to memoize class methods or static class methods. 218 | 219 | Notes: 220 | 221 | - Only class methods and getters/setters can be memoized, not regular functions (they aren't part of the proposal); 222 | - Only [TypeScript’s decorators](https://www.typescriptlang.org/docs/handbook/decorators.html#parameter-decorators) are supported, not [Babel’s](https://babeljs.io/docs/en/babel-plugin-proposal-decorators), which use a different version of the proposal; 223 | - Being an experimental feature, they need to be enabled with `--experimentalDecorators`; follow TypeScript’s docs. 224 | 225 | #### options 226 | 227 | Type: `object` 228 | 229 | Same as options for `memoize()`. 230 | 231 | ```ts 232 | import {memoizeDecorator} from 'memoize'; 233 | 234 | class Example { 235 | index = 0 236 | 237 | @memoizeDecorator() 238 | counter() { 239 | return ++this.index; 240 | } 241 | } 242 | 243 | class ExampleWithOptions { 244 | index = 0 245 | 246 | @memoizeDecorator({maxAge: 1000}) 247 | counter() { 248 | return ++this.index; 249 | } 250 | } 251 | ``` 252 | 253 | ### memoizeClear(fn) 254 | 255 | Clear all cached data of a memoized function. 256 | 257 | #### fn 258 | 259 | Type: `Function` 260 | 261 | The memoized function. 262 | 263 | ## Tips 264 | 265 | ### Cache statistics 266 | 267 | If you want to know how many times your cache had a hit or a miss, you can make use of [stats-map](https://github.com/SamVerschueren/stats-map) as a replacement for the default cache. 268 | 269 | #### Example 270 | 271 | ```js 272 | import memoize from 'memoize'; 273 | import StatsMap from 'stats-map'; 274 | import got from 'got'; 275 | 276 | const cache = new StatsMap(); 277 | const memoizedGot = memoize(got, {cache}); 278 | 279 | await memoizedGot('https://sindresorhus.com'); 280 | await memoizedGot('https://sindresorhus.com'); 281 | await memoizedGot('https://sindresorhus.com'); 282 | 283 | console.log(cache.stats); 284 | //=> {hits: 2, misses: 1} 285 | ``` 286 | 287 | ## Related 288 | 289 | - [p-memoize](https://github.com/sindresorhus/p-memoize) - Memoize promise-returning & async functions 290 | -------------------------------------------------------------------------------- /test-d/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import memoize, {memoizeClear} from '../index.js'; 3 | 4 | // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- Required `string` type 5 | const function_ = (text: string) => Boolean(text); 6 | 7 | expectType(memoize(function_)); 8 | expectType(memoize(function_, {maxAge: 1})); 9 | expectType(memoize(function_, {cacheKey: ([firstArgument]: [string]) => firstArgument})); 10 | expectType( 11 | memoize(function_, { 12 | // The cacheKey returns an array. This isn't deduplicated by a regular Map, but it's valid. The correct solution would be to use ManyKeysMap to deduplicate it correctly 13 | cacheKey: (arguments_: [string]) => arguments_, 14 | cache: new Map<[string], {data: boolean; maxAge: number}>(), 15 | }), 16 | ); 17 | expectType( 18 | // The `firstArgument` of `fn` is of type `string`, so it's used 19 | memoize(function_, {cache: new Map()}), 20 | ); 21 | 22 | /* Overloaded function tests */ 23 | function overloadedFunction(parameter: false): false; 24 | function overloadedFunction(parameter: true): true; 25 | function overloadedFunction(parameter: boolean): boolean { 26 | return parameter; 27 | } 28 | 29 | expectType(memoize(overloadedFunction)); 30 | expectType(memoize(overloadedFunction)(true)); 31 | expectType(memoize(overloadedFunction)(false)); 32 | 33 | memoizeClear(function_); 34 | 35 | // `cacheKey` tests. 36 | // The argument should match the memoized function’s parameters 37 | // eslint-disable-next-line unicorn/prefer-native-coercion-functions -- Required `string` type 38 | memoize((text: string) => Boolean(text), { 39 | cacheKey(arguments_) { 40 | expectType<[string]>(arguments_); 41 | }, 42 | }); 43 | 44 | memoize(() => 1, { 45 | cacheKey(arguments_) { 46 | expectType<[]>(arguments_); // eslint-disable-line @typescript-eslint/ban-types 47 | }, 48 | }); 49 | 50 | // Ensures that the various cache functions infer their arguments type from the return type of `cacheKey` 51 | memoize((_arguments: {key: string}) => 1, { 52 | cacheKey(arguments_: [{key: string}]) { 53 | expectType<[{key: string}]>(arguments_); 54 | return new Date(); 55 | }, 56 | cache: { 57 | get(key) { 58 | expectType(key); 59 | 60 | return { 61 | data: 5, 62 | maxAge: 2, 63 | }; 64 | }, 65 | set(key, data) { 66 | expectType(key); 67 | expectType<{data: number; maxAge: number}>(data); 68 | }, 69 | has(key) { 70 | expectType(key); 71 | return true; 72 | }, 73 | delete(key) { 74 | expectType(key); 75 | }, 76 | clear: () => undefined, 77 | }, 78 | }); 79 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import delay from 'delay'; 3 | import serializeJavascript from 'serialize-javascript'; 4 | import memoize, {memoizeDecorator, memoizeClear} from './index.js'; 5 | 6 | test('memoize', t => { 7 | let index = 0; 8 | const fixture = (a?: unknown, b?: unknown) => index++; 9 | const memoized = memoize(fixture); 10 | t.is(memoized(), 0); 11 | t.is(memoized(), 0); 12 | t.is(memoized(), 0); 13 | t.is(memoized(undefined), 0); 14 | t.is(memoized(undefined), 0); 15 | t.is(memoized('foo'), 1); 16 | t.is(memoized('foo'), 1); 17 | t.is(memoized('foo'), 1); 18 | t.is(memoized('foo', 'bar'), 1); 19 | t.is(memoized('foo', 'bar'), 1); 20 | t.is(memoized('foo', 'bar'), 1); 21 | t.is(memoized(1), 2); 22 | t.is(memoized(1), 2); 23 | t.is(memoized(null), 3); 24 | t.is(memoized(null), 3); 25 | t.is(memoized(fixture), 4); 26 | t.is(memoized(fixture), 4); 27 | t.is(memoized(true), 5); 28 | t.is(memoized(true), 5); 29 | 30 | // Ensure that functions are stored by reference and not by "value" (e.g. their `.toString()` representation) 31 | t.is(memoized(() => index++), 6); 32 | t.is(memoized(() => index++), 7); 33 | }); 34 | 35 | test('cacheKey option', t => { 36 | let index = 0; 37 | const fixture = (..._arguments: any) => index++; 38 | const memoized = memoize(fixture, {cacheKey: ([firstArgument]) => String(firstArgument)}); 39 | t.is(memoized(1), 0); 40 | t.is(memoized(1), 0); 41 | t.is(memoized('1'), 0); 42 | t.is(memoized('2'), 1); 43 | t.is(memoized(2), 1); 44 | }); 45 | 46 | test('memoize with multiple non-primitive arguments', t => { 47 | let index = 0; 48 | const memoized = memoize((a?: unknown, b?: unknown, c?: unknown) => index++, {cacheKey: JSON.stringify}); 49 | t.is(memoized(), 0); 50 | t.is(memoized(), 0); 51 | t.is(memoized({foo: true}, {bar: false}), 1); 52 | t.is(memoized({foo: true}, {bar: false}), 1); 53 | t.is(memoized({foo: true}, {bar: false}, {baz: true}), 2); 54 | t.is(memoized({foo: true}, {bar: false}, {baz: true}), 2); 55 | }); 56 | 57 | test('memoize with regexp arguments', t => { 58 | let index = 0; 59 | const memoized = memoize((a?: unknown) => index++, {cacheKey: serializeJavascript}); 60 | t.is(memoized(), 0); 61 | t.is(memoized(), 0); 62 | t.is(memoized(/Sindre Sorhus/), 1); 63 | t.is(memoized(/Sindre Sorhus/), 1); 64 | t.is(memoized(/Elvin Peng/), 2); 65 | t.is(memoized(/Elvin Peng/), 2); 66 | }); 67 | 68 | test('memoize with Symbol arguments', t => { 69 | let index = 0; 70 | const argument1 = Symbol('fixture1'); 71 | const argument2 = Symbol('fixture2'); 72 | const memoized = memoize((a?: unknown) => index++); 73 | t.is(memoized(), 0); 74 | t.is(memoized(), 0); 75 | t.is(memoized(argument1), 1); 76 | t.is(memoized(argument1), 1); 77 | t.is(memoized(argument2), 2); 78 | t.is(memoized(argument2), 2); 79 | }); 80 | 81 | test('maxAge option', async t => { 82 | let index = 0; 83 | const fixture = (a?: unknown) => index++; 84 | const memoized = memoize(fixture, {maxAge: 100}); 85 | t.is(memoized(1), 0); 86 | t.is(memoized(1), 0); 87 | await delay(50); 88 | t.is(memoized(1), 0); 89 | await delay(200); 90 | t.is(memoized(1), 1); 91 | }); 92 | 93 | test('maxAge option deletes old items', async t => { 94 | let index = 0; 95 | const fixture = (a?: unknown) => index++; 96 | const cache = new Map(); 97 | const deleted: number[] = []; 98 | const _delete = cache.delete.bind(cache); 99 | cache.delete = item => { 100 | deleted.push(item); 101 | return _delete(item); 102 | }; 103 | 104 | const memoized = memoize(fixture, {maxAge: 100, cache}); 105 | t.is(memoized(1), 0); 106 | t.is(memoized(1), 0); 107 | t.is(cache.has(1), true); 108 | await delay(50); 109 | t.is(memoized(1), 0); 110 | t.is(deleted.length, 0); 111 | await delay(200); 112 | t.is(memoized(1), 1); 113 | t.is(deleted.length, 1); 114 | t.is(deleted[0], 1); 115 | }); 116 | 117 | test('maxAge items are deleted even if function throws', async t => { 118 | let index = 0; 119 | const fixture = (a?: unknown) => { 120 | if (index === 1) { 121 | throw new Error('failure'); 122 | } 123 | 124 | return index++; 125 | }; 126 | 127 | const cache = new Map(); 128 | const memoized = memoize(fixture, {maxAge: 100, cache}); 129 | t.is(memoized(1), 0); 130 | t.is(memoized(1), 0); 131 | t.is(cache.size, 1); 132 | await delay(50); 133 | t.is(memoized(1), 0); 134 | await delay(200); 135 | t.throws(() => { 136 | memoized(1); 137 | }, {message: 'failure'}); 138 | t.is(cache.size, 0); 139 | }); 140 | 141 | test('cache option', t => { 142 | let index = 0; 143 | const fixture = (..._arguments: any) => index++; 144 | const memoized = memoize(fixture, { 145 | cache: new WeakMap(), 146 | cacheKey: ([firstArgument]: [ReturnValue]): ReturnValue => firstArgument, 147 | }); 148 | const foo = {}; 149 | const bar = {}; 150 | t.is(memoized(foo), 0); 151 | t.is(memoized(foo), 0); 152 | t.is(memoized(bar), 1); 153 | t.is(memoized(bar), 1); 154 | }); 155 | 156 | test('promise support', async t => { 157 | let index = 0; 158 | const memoized = memoize(async (a?: unknown) => index++); 159 | t.is(await memoized(), 0); 160 | t.is(await memoized(), 0); 161 | t.is(await memoized(10), 1); 162 | }); 163 | 164 | test('preserves the original function name', t => { 165 | t.is(memoize(function foo() {}).name, 'foo'); // eslint-disable-line func-names, @typescript-eslint/no-empty-function 166 | }); 167 | 168 | test('.clear()', t => { 169 | let index = 0; 170 | const fixture = () => index++; 171 | const memoized = memoize(fixture); 172 | t.is(memoized(), 0); 173 | t.is(memoized(), 0); 174 | memoizeClear(memoized); 175 | t.is(memoized(), 1); 176 | t.is(memoized(), 1); 177 | }); 178 | 179 | test('prototype support', t => { 180 | class Unicorn { 181 | index = 0; 182 | foo() { 183 | return this.index++; 184 | } 185 | } 186 | 187 | Unicorn.prototype.foo = memoize(Unicorn.prototype.foo); 188 | 189 | const unicorn = new Unicorn(); 190 | 191 | t.is(unicorn.foo(), 0); 192 | t.is(unicorn.foo(), 0); 193 | t.is(unicorn.foo(), 0); 194 | }); 195 | 196 | test('memoizeDecorator()', t => { 197 | let returnValue = 1; 198 | const returnValue2 = 101; 199 | 200 | class TestClass { 201 | @memoizeDecorator() 202 | counter() { 203 | return returnValue++; 204 | } 205 | 206 | @memoizeDecorator() 207 | counter2() { 208 | return returnValue2; 209 | } 210 | } 211 | 212 | const alpha = new TestClass(); 213 | t.is(alpha.counter(), 1); 214 | t.is(alpha.counter(), 1, 'The method should be memoized'); 215 | t.is(alpha.counter2(), 101, 'The method should be memoized separately from the other one'); 216 | 217 | const beta = new TestClass(); 218 | t.is(beta.counter(), 2, 'The method should not be memoized across instances'); 219 | }); 220 | 221 | test('memoizeClear() throws when called with a plain function', t => { 222 | t.throws(() => { 223 | memoizeClear(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function 224 | }, { 225 | message: 'Can\'t clear a function that was not memoized!', 226 | instanceOf: TypeError, 227 | }); 228 | }); 229 | 230 | test('memoizeClear() throws when called on an unclearable cache', t => { 231 | const fixture = () => 1; 232 | const memoized = memoize(fixture, { 233 | cache: new WeakMap(), 234 | }); 235 | 236 | t.throws(() => { 237 | memoizeClear(memoized); 238 | }, { 239 | message: 'The cache Map can\'t be cleared!', 240 | instanceOf: TypeError, 241 | }); 242 | }); 243 | 244 | test('maxAge - cache item expires after specified duration', async t => { 245 | let index = 0; 246 | const fixture = () => index++; 247 | const memoized = memoize(fixture, {maxAge: 100}); 248 | 249 | t.is(memoized(), 0); // Initial call, cached 250 | t.is(memoized(), 0); // Subsequent call, still cached 251 | await delay(150); // Wait for longer than maxAge 252 | t.is(memoized(), 1); // Cache expired, should compute again 253 | }); 254 | 255 | test('maxAge - cache expiration timing is accurate', async t => { 256 | let index = 0; 257 | const fixture = () => index++; 258 | const memoized = memoize(fixture, {maxAge: 100}); 259 | 260 | t.is(memoized(), 0); 261 | await delay(90); // Wait for slightly less than maxAge 262 | t.is(memoized(), 0); // Should still be cached 263 | await delay(20); // Total delay now exceeds maxAge 264 | t.is(memoized(), 1); // Should recompute as cache has expired 265 | }); 266 | 267 | test('maxAge - expired items are not present in cache', async t => { 268 | let index = 0; 269 | const fixture = () => index++; 270 | const cache = new Map(); 271 | const memoized = memoize(fixture, {maxAge: 100, cache}); 272 | 273 | memoized(); // Call to cache the result 274 | await delay(150); // Wait for cache to expire 275 | memoized(); // Recompute and recache 276 | t.is(cache.size, 1); // Only one item should be in the cache 277 | }); 278 | 279 | test('maxAge - complex arguments and cache expiration', async t => { 280 | let index = 0; 281 | const fixture = object => index++; 282 | const memoized = memoize(fixture, {maxAge: 100, cacheKey: JSON.stringify}); 283 | 284 | const argument = {key: 'value'}; 285 | t.is(memoized(argument), 0); 286 | await delay(150); 287 | t.is(memoized(argument), 1); // Argument is the same, but should recompute due to expiration 288 | }); 289 | 290 | test('maxAge - concurrent calls return cached value', async t => { 291 | let index = 0; 292 | const fixture = () => index++; 293 | const memoized = memoize(fixture, {maxAge: 100}); 294 | 295 | t.is(memoized(), 0); 296 | await delay(50); // Delay less than maxAge 297 | t.is(memoized(), 0); // Should return cached value 298 | }); 299 | 300 | test('maxAge - different arguments have separate expirations', async t => { 301 | let index = 0; 302 | const fixture = x => index++; 303 | const memoized = memoize(fixture, {maxAge: 100}); 304 | 305 | t.is(memoized('a'), 0); 306 | await delay(150); // Expire the cache for 'a' 307 | t.is(memoized('b'), 1); // 'b' should be a separate cache entry 308 | t.is(memoized('a'), 2); // 'a' should be recomputed 309 | }); 310 | 311 | test('maxAge - zero maxAge means no caching', t => { 312 | let index = 0; 313 | const fixture = () => index++; 314 | const memoized = memoize(fixture, {maxAge: 0}); 315 | 316 | t.is(memoized(), 0); 317 | t.is(memoized(), 1); // No caching, should increment 318 | }); 319 | 320 | test('maxAge - immediate expiration', async t => { 321 | let index = 0; 322 | const fixture = () => index++; 323 | const memoized = memoize(fixture, {maxAge: 1}); 324 | t.is(memoized(), 0); 325 | await delay(10); 326 | t.is(memoized(), 1); // Cache should expire immediately 327 | }); 328 | 329 | test('maxAge - high concurrency', async t => { 330 | let index = 0; 331 | const fixture = () => index++; 332 | const memoized = memoize(fixture, {maxAge: 50}); 333 | 334 | // Simulate concurrent calls 335 | for (let job = 0; job < 10_000; job++) { 336 | memoized(); 337 | } 338 | 339 | await delay(100); 340 | t.is(memoized(), 1); 341 | }); 342 | 343 | test('maxAge dependent on function parameters', async t => { 344 | let index = 0; 345 | const fixture = (x: number) => index++; 346 | const memoized = memoize(fixture, { 347 | maxAge: x => x * 100, 348 | }); 349 | 350 | t.is(memoized(1), 0); // Initial call, cached 351 | await delay(50); 352 | t.is(memoized(1), 0); // Still cached 353 | await delay(60); 354 | t.is(memoized(1), 1); // Cache expired, should compute again 355 | 356 | t.is(memoized(2), 2); // Initial call with different parameter, cached 357 | await delay(210); 358 | t.is(memoized(2), 3); // Cache expired, should compute again 359 | }); 360 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sindresorhus/tsconfig", 3 | "compilerOptions": { 4 | "experimentalDecorators": true 5 | }, 6 | "files": [ 7 | "index.ts" 8 | ], 9 | "ts-node": { 10 | "transpileOnly": true 11 | } 12 | } 13 | --------------------------------------------------------------------------------