/**", "**/node_modules/**"],
11 | "smartStep": true,
12 | "type": "node"
13 | }
14 | ],
15 | "version": "0.2.0"
16 | }
17 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
3 | "editor.defaultFormatter": "esbenp.prettier-vscode",
4 | "editor.formatOnSave": true,
5 | "editor.rulers": [80],
6 | "eslint.probe": [
7 | "javascript",
8 | "javascriptreact",
9 | "json",
10 | "jsonc",
11 | "markdown",
12 | "typescript",
13 | "typescriptreact",
14 | "yaml"
15 | ],
16 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
17 | "typescript.tsdk": "node_modules/typescript/lib"
18 | }
19 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "tasks": [
3 | {
4 | "detail": "Build the project",
5 | "label": "build",
6 | "script": "build",
7 | "type": "npm"
8 | }
9 | ],
10 | "version": "2.0.0"
11 | }
12 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | 'Software'), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | cached-factory
2 |
3 |
4 | Creates and caches values under keys.
5 | 🏭
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | ## Usage
22 |
23 | ```shell
24 | npm i cached-factory
25 | ```
26 |
27 | ```ts
28 | import { greet } from "cached-factory";
29 |
30 | greet("Hello, world! 🏭");
31 | ```
32 |
33 | ## Development
34 |
35 | See [`.github/CONTRIBUTING.md`](./.github/CONTRIBUTING.md), then [`.github/DEVELOPMENT.md`](./.github/DEVELOPMENT.md).
36 | Thanks! 🏭
37 |
38 | ## Contributors
39 |
40 |
41 |
42 |
43 |
44 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | > 💝 This package was templated with [`create-typescript-app`](https://github.com/JoshuaKGoldberg/create-typescript-app) using the [Bingo framework](https://create.bingo).
59 |
--------------------------------------------------------------------------------
/cspell.json:
--------------------------------------------------------------------------------
1 | {
2 | "dictionaries": ["npm", "node", "typescript"],
3 | "ignorePaths": [
4 | ".all-contributorsrc",
5 | ".github",
6 | "CHANGELOG.md",
7 | "coverage",
8 | "lib",
9 | "node_modules",
10 | "pnpm-lock.yaml"
11 | ],
12 | "words": [
13 | "Codecov",
14 | "apexskier",
15 | "automerge",
16 | "commitlint",
17 | "contributorsrc",
18 | "conventionalcommits",
19 | "joshuakgoldberg",
20 | "knip",
21 | "lcov",
22 | "markdownlintignore",
23 | "npmpackagejsonlintrc",
24 | "outro",
25 | "packagejson",
26 | "tseslint",
27 | "tsup"
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import comments from "@eslint-community/eslint-plugin-eslint-comments/configs";
2 | import eslint from "@eslint/js";
3 | import vitest from "@vitest/eslint-plugin";
4 | import jsdoc from "eslint-plugin-jsdoc";
5 | import jsonc from "eslint-plugin-jsonc";
6 | import markdown from "eslint-plugin-markdown";
7 | import n from "eslint-plugin-n";
8 | import packageJson from "eslint-plugin-package-json";
9 | import perfectionist from "eslint-plugin-perfectionist";
10 | import * as regexp from "eslint-plugin-regexp";
11 | import yml from "eslint-plugin-yml";
12 | import tseslint from "typescript-eslint";
13 |
14 | export default tseslint.config(
15 | {
16 | ignores: ["**/*.snap", "coverage", "lib", "node_modules", "pnpm-lock.yaml"],
17 | },
18 | { linterOptions: { reportUnusedDisableDirectives: "error" } },
19 | eslint.configs.recommended,
20 | comments.recommended,
21 | jsdoc.configs["flat/contents-typescript-error"],
22 | jsdoc.configs["flat/logical-typescript-error"],
23 | jsdoc.configs["flat/stylistic-typescript-error"],
24 | jsonc.configs["flat/recommended-with-json"],
25 | markdown.configs.recommended,
26 | n.configs["flat/recommended"],
27 | packageJson.configs.recommended,
28 | perfectionist.configs["recommended-natural"],
29 | regexp.configs["flat/recommended"],
30 | {
31 | extends: [
32 | tseslint.configs.strictTypeChecked,
33 | tseslint.configs.stylisticTypeChecked,
34 | ],
35 | files: ["**/*.{js,ts}"],
36 | languageOptions: {
37 | parserOptions: {
38 | projectService: { allowDefaultProject: ["*.config.*s"] },
39 | tsconfigRootDir: import.meta.dirname,
40 | },
41 | },
42 | rules: {
43 | // Stylistic concerns that don't interfere with Prettier
44 | "logical-assignment-operators": [
45 | "error",
46 | "always",
47 | { enforceForIfStatements: true },
48 | ],
49 | "no-useless-rename": "error",
50 | "object-shorthand": "error",
51 | "operator-assignment": "error",
52 | },
53 | settings: {
54 | perfectionist: { partitionByComment: true, type: "natural" },
55 | vitest: { typecheck: true },
56 | },
57 | },
58 | {
59 | extends: [tseslint.configs.disableTypeChecked],
60 | files: ["**/*.md/*.ts"],
61 | rules: { "n/no-missing-import": "off" },
62 | },
63 | {
64 | extends: [vitest.configs.recommended],
65 | files: ["**/*.test.*"],
66 | rules: { "@typescript-eslint/no-unsafe-assignment": "off" },
67 | },
68 | {
69 | extends: [yml.configs["flat/standard"], yml.configs["flat/prettier"]],
70 | files: ["**/*.{yml,yaml}"],
71 | rules: {
72 | "yml/file-extension": ["error", { extension: "yml" }],
73 | "yml/sort-keys": [
74 | "error",
75 | { order: { type: "asc" }, pathPattern: "^.*$" },
76 | ],
77 | "yml/sort-sequence-values": [
78 | "error",
79 | { order: { type: "asc" }, pathPattern: "^.*$" },
80 | ],
81 | },
82 | },
83 | );
84 |
--------------------------------------------------------------------------------
/knip.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/knip@5.46.0/schema.json",
3 | "entry": ["src/**/*.test.*", "src/index.ts"],
4 | "ignoreExportsUsedInFile": { "interface": true, "type": true },
5 | "project": ["src/**/*.ts"]
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cached-factory",
3 | "version": "0.1.0",
4 | "description": "Creates and caches values under keys. 🏭",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/JoshuaKGoldberg/cached-factory.git"
8 | },
9 | "license": "MIT",
10 | "author": {
11 | "name": "joshuakgoldberg",
12 | "email": "npm@joshuakgoldberg.com"
13 | },
14 | "type": "module",
15 | "main": "lib/index.js",
16 | "files": [
17 | "LICENSE.md",
18 | "README.md",
19 | "lib/",
20 | "package.json"
21 | ],
22 | "scripts": {
23 | "build": "tsup",
24 | "format": "prettier .",
25 | "format:write": "pnpm format --write",
26 | "lint": "eslint . --max-warnings 0",
27 | "lint:knip": "knip",
28 | "lint:md": "markdownlint \"**/*.md\" \".github/**/*.md\" --rules sentences-per-line",
29 | "lint:packages": "pnpm dedupe --check",
30 | "lint:spelling": "cspell \"**\" \".github/**/*\"",
31 | "prepare": "husky",
32 | "test": "vitest",
33 | "tsc": "tsc"
34 | },
35 | "lint-staged": {
36 | "*": "prettier --ignore-unknown --write"
37 | },
38 | "devDependencies": {
39 | "@eslint-community/eslint-plugin-eslint-comments": "4.5.0",
40 | "@eslint/js": "9.28.0",
41 | "@release-it/conventional-changelog": "10.0.0",
42 | "@types/eslint-plugin-markdown": "2.0.2",
43 | "@types/node": "22.15.0",
44 | "@vitest/coverage-v8": "3.1.1",
45 | "@vitest/eslint-plugin": "1.2.0",
46 | "console-fail-test": "0.5.0",
47 | "create-typescript-app": "2.42.0",
48 | "cspell": "9.0.0",
49 | "eslint": "9.27.0",
50 | "eslint-plugin-jsdoc": "50.6.8",
51 | "eslint-plugin-jsonc": "2.20.0",
52 | "eslint-plugin-markdown": "5.1.0",
53 | "eslint-plugin-n": "17.16.2",
54 | "eslint-plugin-package-json": "0.31.0",
55 | "eslint-plugin-perfectionist": "4.13.0",
56 | "eslint-plugin-regexp": "2.7.0",
57 | "eslint-plugin-yml": "1.18.0",
58 | "husky": "9.1.7",
59 | "knip": "5.59.0",
60 | "lint-staged": "16.1.0",
61 | "markdownlint": "0.38.0",
62 | "markdownlint-cli": "0.45.0",
63 | "prettier": "3.5.3",
64 | "prettier-plugin-curly": "0.3.1",
65 | "prettier-plugin-packagejson": "2.5.10",
66 | "prettier-plugin-sh": "0.17.0",
67 | "release-it": "19.0.1",
68 | "sentences-per-line": "0.3.0",
69 | "tsup": "8.5.0",
70 | "typescript": "5.8.2",
71 | "typescript-eslint": "8.33.0",
72 | "vitest": "3.1.1"
73 | },
74 | "packageManager": "pnpm@10.11.0",
75 | "engines": {
76 | "node": ">=18"
77 | },
78 | "publishConfig": {
79 | "provenance": true
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/CachedFactory.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it, vi } from "vitest";
2 |
3 | import { CachedFactory } from "./index.js";
4 |
5 | describe("CachedFactory", () => {
6 | it("creates a new value under a key when the value doesn't yet exist", () => {
7 | const value = "value-first";
8 | const getter = vi.fn().mockReturnValue(value);
9 | const cachedFactory = new CachedFactory(getter);
10 |
11 | const actual = cachedFactory.get("key-first");
12 |
13 | expect(actual).toEqual(value);
14 | });
15 |
16 | it("reuses an existing value under a key when the value doesn't yet exist", () => {
17 | const value = "value-first";
18 | const getter = vi.fn().mockReturnValueOnce(value).mockReturnValue("second");
19 | const cachedFactory = new CachedFactory(getter);
20 |
21 | const actual = cachedFactory.get("key-first");
22 |
23 | expect(actual).toEqual(value);
24 | });
25 |
26 | it("caches a values under provided keys when a key is repeated", () => {
27 | const getter = vi.fn().mockImplementation((key: string) => key);
28 | const cachedFactory = new CachedFactory(getter);
29 |
30 | expect(cachedFactory.get("key-first")).toEqual("key-first");
31 | expect(cachedFactory.get("key-first")).toEqual("key-first");
32 |
33 | expect(getter.mock.calls).toEqual([["key-first"]]);
34 | });
35 |
36 | it("caches values under provided keys when multiple keys are used", () => {
37 | const getter = vi.fn().mockImplementation((key: string) => key);
38 | const cachedFactory = new CachedFactory(getter);
39 |
40 | expect(cachedFactory.get("key-first")).toEqual("key-first");
41 | expect(cachedFactory.get("key-second")).toEqual("key-second");
42 | expect(cachedFactory.get("key-first")).toEqual("key-first");
43 | expect(cachedFactory.get("key-second")).toEqual("key-second");
44 | expect(cachedFactory.get("key-second")).toEqual("key-second");
45 | expect(cachedFactory.get("key-first")).toEqual("key-first");
46 | expect(cachedFactory.get("key-first")).toEqual("key-first");
47 |
48 | expect(getter.mock.calls).toEqual([["key-first"], ["key-second"]]);
49 | });
50 |
51 | it("creates a new value under a key when .get() is used after .clear()", () => {
52 | const secondValue = "second";
53 | const getter = vi
54 | .fn()
55 | .mockReturnValueOnce("first")
56 | .mockReturnValueOnce(secondValue);
57 |
58 | const cachedFactory = new CachedFactory(getter);
59 |
60 | cachedFactory.get("key");
61 | cachedFactory.clear();
62 |
63 | const second = cachedFactory.get("key");
64 | expect(second).toEqual(secondValue);
65 | });
66 |
67 | it("returns entries when .entries() is called", () => {
68 | const value = "value";
69 | const getter = vi.fn().mockReturnValueOnce(value).mockReturnValue("second");
70 | const cachedFactory = new CachedFactory(getter);
71 |
72 | const key = "key";
73 | cachedFactory.get(key);
74 |
75 | const actual = cachedFactory.entries();
76 |
77 | expect(Array.from(actual)).toEqual([[key, value]]);
78 | });
79 | });
80 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export type Factory = (key: Key) => Value;
2 |
3 | export class CachedFactory {
4 | #cache = new Map();
5 | #getter: Factory;
6 |
7 | constructor(factory: Factory) {
8 | this.#getter = factory;
9 | }
10 |
11 | clear() {
12 | this.#cache.clear();
13 | }
14 |
15 | entries() {
16 | return this.#cache.entries();
17 | }
18 |
19 | get(key: Key) {
20 | const existing = this.#cache.get(key);
21 | if (existing) {
22 | return existing;
23 | }
24 |
25 | const value = this.#getter(key);
26 | this.#cache.set(key, value);
27 | return value;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | { "extends": "./tsconfig.json", "include": ["."] }
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "declaration": true,
4 | "declarationMap": true,
5 | "esModuleInterop": true,
6 | "module": "NodeNext",
7 | "moduleResolution": "NodeNext",
8 | "noEmit": true,
9 | "resolveJsonModule": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "target": "ES2022"
13 | },
14 | "include": ["src"]
15 | }
16 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | bundle: false,
5 | clean: true,
6 | dts: true,
7 | entry: ["src/**/*.ts", "!src/**/*.test.*"],
8 | format: "esm",
9 | outDir: "lib",
10 | });
11 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vitest/config";
2 |
3 | export default defineConfig({
4 | test: {
5 | clearMocks: true,
6 | coverage: {
7 | all: true,
8 | include: ["src"],
9 | reporter: ["html", "lcov"],
10 | },
11 | exclude: ["lib", "node_modules"],
12 | setupFiles: ["console-fail-test/setup"],
13 | },
14 | });
15 |
--------------------------------------------------------------------------------