diff --git a/.changeset/polite-spies-happen.md b/.changeset/polite-spies-happen.md new file mode 100644 index 0000000..6b3dce8 --- /dev/null +++ b/.changeset/polite-spies-happen.md @@ -0,0 +1,5 @@ +--- +'@js-utils-kit/types': minor +--- + +Add `PlainObject` type representing a generic JavaScript object with arbitrary property keys diff --git a/.changeset/solid-masks-spend.md b/.changeset/solid-masks-spend.md new file mode 100644 index 0000000..89baf28 --- /dev/null +++ b/.changeset/solid-masks-spend.md @@ -0,0 +1,43 @@ +--- +'@js-utils-kit/object': minor +--- + +Add `deepMerge` utility for recursively merging objects with configurable array strategies (`replace`, `concat`, `merge`). + +Deprecate `mergeObj` in favor of `deepMerge`. + +#### Migration + +```diff +- import { mergeObj } from "@js-utils-kit/object"; ++ import { deepMerge } from "@js-utils-kit/object"; + +- const result = mergeObj( +- false, +- { a: 1 }, +- { b: 2 } +- ); ++ const result = deepMerge( ++ { a: 1 }, ++ { b: 2 } ++ ); + +- mergeObj( +- true, +- { items: [1, 2] }, +- { items: [3] } +- ); ++ deepMerge( ++ { items: [1, 2] }, ++ { items: [3] }, ++ { arrayStrategy: "concat" } ++ ); +``` + +```diff +- mergeObj(true, ...) ++ deepMerge(..., { arrayStrategy: "concat" }) + +- mergeObj(false, ...) ++ deepMerge(...) +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 43153bf..45b6358 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,7 +19,7 @@ js-utils-kit/ ├── packages/ # All publishable/internal packages ├── scripts/ # Build & development tooling ├── eslint.config.ts # ESLint configuration -├── tsconfig.base.json # Base TypeScript config +├── tsconfig.json # TypeScript config ├── turbo.json # Turbo pipeline config ├── typedoc.json # Documentation config ├── pnpm-workspace.yaml # Workspace definition diff --git a/eslint.config.ts b/eslint.config.ts index 47ad608..dfcf487 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -27,7 +27,7 @@ export default defineConfig([ languageOptions: { parser: tsParser, parserOptions: { - project: ['./tsconfig.base.json', './packages/**/*/tsconfig.json'], + project: ['./tsconfig.json', './packages/**/*/tsconfig.json'], tsconfigRootDir: process.cwd(), }, }, diff --git a/exports.json b/exports.json index fa8a604..3337a9a 100644 --- a/exports.json +++ b/exports.json @@ -1,12 +1,12 @@ { "summary": { - "totalExports": 177, - "deprecatedExports": 0, + "totalExports": 180, + "deprecatedExports": 1, "classExports": 0, - "functionExports": 79, + "functionExports": 80, "variableExports": 81, - "typeExports": 16, - "valueExports": 160, + "typeExports": 18, + "valueExports": 161, "totalPackages": 12 }, "exportNames": { @@ -47,6 +47,7 @@ "randomFloat", "randomInt", "deepFreeze", + "deepMerge", "mergeObj", "detectPM", "capitalize", @@ -177,6 +178,7 @@ ], "typeExports": [ "SPDX", + "DeepMergeOptions", "PackageManager", "DetectPMOptions", "DetectPMResult", @@ -187,13 +189,14 @@ "HttpStatusCode", "MinuteOrSecond", "PackageJson", + "PlainObject", "Trim", "CommitScope", "CommitType", "CommitTypeMeta", "DeepPartial" ], - "deprecatedExports": [] + "deprecatedExports": ["mergeObj"] }, "exports": { "@js-utils-kit/array": [ @@ -986,12 +989,28 @@ "line": 99 }, { - "name": "mergeObj", + "name": "deepMerge", "declarationKind": "FunctionDeclaration", "exportKind": "function", "deprecated": false, + "filePath": "packages/@js-utils-kit/object/src/deepMerge.ts", + "line": 91 + }, + { + "name": "DeepMergeOptions", + "declarationKind": "InterfaceDeclaration", + "exportKind": "type", + "deprecated": false, + "filePath": "packages/@js-utils-kit/object/src/deepMerge.ts", + "line": 7 + }, + { + "name": "mergeObj", + "declarationKind": "FunctionDeclaration", + "exportKind": "function", + "deprecated": true, "filePath": "packages/@js-utils-kit/object/src/mergeObj.ts", - "line": 39 + "line": 40 } ], "@js-utils-kit/pm": [ @@ -1417,6 +1436,14 @@ "filePath": "packages/@js-utils-kit/types/src/PackageJson.ts", "line": 56 }, + { + "name": "PlainObject", + "declarationKind": "TypeAliasDeclaration", + "exportKind": "type", + "deprecated": false, + "filePath": "packages/@js-utils-kit/types/src/PlainObject.ts", + "line": 2 + }, { "name": "Trim", "declarationKind": "InterfaceDeclaration", diff --git a/package.json b/package.json index 0f0751f..de2ee43 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "scripts": { "prebuild": "rimraf --glob \"packages/**/dist\"", "build": "turbo run build", - "build:docs": "typedoc --tsconfig tsconfig.base.json", + "build:docs": "typedoc", "changeset": "changeset", "checks": "node scripts/checks.js", "fmt": "prettier --write .", diff --git a/packages/@js-utils-kit/array/tsconfig.json b/packages/@js-utils-kit/array/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/array/tsconfig.json +++ b/packages/@js-utils-kit/array/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/charset/tsconfig.json b/packages/@js-utils-kit/charset/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/charset/tsconfig.json +++ b/packages/@js-utils-kit/charset/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/constants/tsconfig.json b/packages/@js-utils-kit/constants/tsconfig.json index 43b1ad6..1c4d006 100644 --- a/packages/@js-utils-kit/constants/tsconfig.json +++ b/packages/@js-utils-kit/constants/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "scripts"] } diff --git a/packages/@js-utils-kit/core/tsconfig.json b/packages/@js-utils-kit/core/tsconfig.json index 98e3cfe..9b376c2 100644 --- a/packages/@js-utils-kit/core/tsconfig.json +++ b/packages/@js-utils-kit/core/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src"] } diff --git a/packages/@js-utils-kit/env/tsconfig.json b/packages/@js-utils-kit/env/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/env/tsconfig.json +++ b/packages/@js-utils-kit/env/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/fs/tsconfig.json b/packages/@js-utils-kit/fs/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/fs/tsconfig.json +++ b/packages/@js-utils-kit/fs/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/number/tsconfig.json b/packages/@js-utils-kit/number/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/number/tsconfig.json +++ b/packages/@js-utils-kit/number/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/object/package.json b/packages/@js-utils-kit/object/package.json index e2a7315..e85accd 100644 --- a/packages/@js-utils-kit/object/package.json +++ b/packages/@js-utils-kit/object/package.json @@ -38,5 +38,8 @@ "build": "tsdown", "test": "vitest run" }, - "dependencies": {} + "dependencies": { + "@js-utils-kit/types": "workspace:*", + "@js-utils-kit/valid": "workspace:*" + } } diff --git a/packages/@js-utils-kit/object/src/deepMerge.ts b/packages/@js-utils-kit/object/src/deepMerge.ts new file mode 100644 index 0000000..477a9ca --- /dev/null +++ b/packages/@js-utils-kit/object/src/deepMerge.ts @@ -0,0 +1,155 @@ +import { PlainObject } from '@js-utils-kit/types'; +import { isArray, isObject } from '@js-utils-kit/valid'; + +/** + * Configuration options for {@link deepMerge}. + */ +export interface DeepMergeOptions { + /** + * Defines how arrays should be handled when merging. + * + * - `replace` - the incoming array replaces the existing array + * - `concat` - arrays are concatenated + * - `merge` - arrays are merged with duplicate values removed + * + * @defaultValue "replace" + */ + arrayStrategy?: 'replace' | 'merge' | 'concat'; +} + +const OPTION_KEYS = ['arrayStrategy'] as const satisfies readonly (keyof DeepMergeOptions)[]; + +function isDeepMergeOptions(value: DeepMergeOptions) { + if (!isObject(value)) return false; + + const keys = Object.keys(value); + + return keys.every((k) => (OPTION_KEYS as readonly string[]).includes(k)); +} + +/** + * Deeply merges multiple objects into a new object. + * + * Later objects override properties from earlier ones. + * + * @remarks + * - The merge is **immutable** input objects are never modified. + * - Primitive values from later objects override earlier values. + * - Nested objects are merged recursively. + * - Arrays are handled based on {@link DeepMergeOptions.arrayStrategy | arrayStrategy}. + * + * @typeParam T - The object type being merged. + * + * @returns A new object containing the merged properties. + * + * @example Basic merge + * ```ts + * deepMerge( + * { a: 1 }, + * { b: 2 } + * ) + * + * // Result + * { a: 1, b: 2 } + * ``` + * + * @example Nested object merge + * ```ts + * deepMerge( + * { config: { port: 3000 } }, + * { config: { host: "localhost" } } + * ) + * + * // Result + * { config: { port: 3000, host: "localhost" } } + * ``` + * + * @example Array concatenation + * ```ts + * deepMerge( + * { items: [1, 2] }, + * { items: [3] }, + * { arrayStrategy: "concat" } + * ) + * + * // Result + * { items: [1, 2, 3] } + * ``` + * + * @example Array merge (deduplicated) + * ```ts + * deepMerge( + * { items: [1, 2] }, + * { items: [2, 3] }, + * { arrayStrategy: "merge" } + * ) + * + * // Result + * { items: [1, 2, 3] } + * ``` + */ +export function deepMerge( + /** + * Objects to merge. + * + * The last argument may optionally be a {@link DeepMergeOptions} object. + */ + ...params: (T | DeepMergeOptions)[] +): T { + let options: DeepMergeOptions = {}; + + if (params.length > 0 && isDeepMergeOptions(params[params.length - 1])) { + options = params.pop() as DeepMergeOptions; + } + + const result: PlainObject = {}; + const { arrayStrategy = 'replace' } = options; + + for (const param of params) { + if (!isObject(param)) continue; + + for (const key of Object.keys(param)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; + + const incoming = (param as PlainObject)[key]; + const existing = result[key]; + + // Array + if (isArray(existing) && isArray(incoming)) { + switch (arrayStrategy) { + case 'concat': + result[key] = [...(existing as unknown[]), ...(incoming as unknown[])]; + break; + + case 'merge': + result[key] = [...new Set([...(existing as unknown[]), ...(incoming as unknown[])])]; + break; + + case 'replace': + default: + result[key] = [...(incoming as unknown[])]; + } + + continue; + } + + // Object + if (isObject(incoming)) { + result[key] = + isObject(existing) && !Array.isArray(existing) + ? deepMerge(existing as PlainObject, incoming as PlainObject, { + arrayStrategy, + }) + : deepMerge({}, incoming as PlainObject, { + arrayStrategy, + }); + + continue; + } + + result[key] = incoming; + } + } + + return result as T; +} diff --git a/packages/@js-utils-kit/object/src/index.ts b/packages/@js-utils-kit/object/src/index.ts index 85f1caa..a2f4ef4 100644 --- a/packages/@js-utils-kit/object/src/index.ts +++ b/packages/@js-utils-kit/object/src/index.ts @@ -4,4 +4,5 @@ * @module object */ export * from './deepFreeze'; +export * from './deepMerge'; export * from './mergeObj'; diff --git a/packages/@js-utils-kit/object/src/mergeObj.ts b/packages/@js-utils-kit/object/src/mergeObj.ts index 750e71a..5502fa3 100644 --- a/packages/@js-utils-kit/object/src/mergeObj.ts +++ b/packages/@js-utils-kit/object/src/mergeObj.ts @@ -15,6 +15,7 @@ * * @returns A new object containing deeply merged keys and values. * + * @deprecated Use {@link deepMerge} instead. * * @example * ```ts diff --git a/packages/@js-utils-kit/object/test/deepMerge.test.ts b/packages/@js-utils-kit/object/test/deepMerge.test.ts new file mode 100644 index 0000000..27fff45 --- /dev/null +++ b/packages/@js-utils-kit/object/test/deepMerge.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { deepMerge } from '../src'; + +describe('basic merging', () => { + it('merges basic objects', () => { + const result = deepMerge({ a: 1 }, { b: 2 }); + + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('later values override earlier ones', () => { + const result = deepMerge({ a: 1 }, { a: 2 }); + + expect(result).toEqual({ a: 2 }); + }); + + it('handles multiple objects', () => { + const result = deepMerge({ a: 1 }, { b: 2 }, { c: 3 }); + + expect(result).toEqual({ + a: 1, + b: 2, + c: 3, + }); + }); + + it('handles empty inputs', () => { + const result = deepMerge({}); + + expect(result).toEqual({}); + }); +}); + +describe('nested objects', () => { + it('merges nested objects', () => { + const result = deepMerge({ config: { port: 3000 } }, { config: { host: 'localhost' } }); + + expect(result).toEqual({ + config: { + port: 3000, + host: 'localhost', + }, + }); + }); + + it('handles deep nested merges', () => { + const result = deepMerge({ a: { b: { c: 1 } } }, { a: { b: { d: 2 } } }); + + expect(result).toEqual({ + a: { + b: { + c: 1, + d: 2, + }, + }, + }); + }); +}); + +describe('array strategies', () => { + it('replaces arrays by default', () => { + const result = deepMerge({ items: [1, 2] }, { items: [3] }); + + expect(result).toEqual({ + items: [3], + }); + }); + + it('concatenates arrays', () => { + const result = deepMerge({ items: [1, 2] }, { items: [3] }, { arrayStrategy: 'concat' }); + + expect(result).toEqual({ + items: [1, 2, 3], + }); + }); + + it('merges arrays without duplicates', () => { + const result = deepMerge({ items: [1, 2] }, { items: [2, 3] }, { arrayStrategy: 'merge' }); + + expect(result).toEqual({ + items: [1, 2, 3], + }); + }); +}); + +describe('value replacement', () => { + it('handles primitive overrides', () => { + const result = deepMerge({ a: { b: 1 } }, { a: 5 }); + + expect(result).toEqual({ a: 5 }); + }); + + it('handles object replacing primitive', () => { + const result = deepMerge({ a: 1 }, { a: { b: 2 } }); + + expect(result).toEqual({ + a: { b: 2 }, + }); + }); +}); + +describe('immutability', () => { + it('does not mutate input objects', () => { + const obj1 = { a: 1 }; + const obj2 = { b: 2 }; + + const result = deepMerge(obj1, obj2 as unknown as object); + + expect(result).toEqual({ a: 1, b: 2 }); + expect(obj1).toEqual({ a: 1 }); + expect(obj2).toEqual({ b: 2 }); + }); +}); + +describe('invalid inputs', () => { + it('ignores non-object values in params', () => { + const result = deepMerge({ a: 1 }, null as unknown as object, { b: 2 }); + + expect(result).toEqual({ + a: 1, + b: 2, + }); + }); + + it('ignores array passed as options', () => { + const result = deepMerge({ a: 1 }, { b: 2 }, [] as unknown as object); + + expect(result).toEqual({ + a: 1, + b: 2, + }); + }); +}); diff --git a/packages/@js-utils-kit/object/tsconfig.json b/packages/@js-utils-kit/object/tsconfig.json index 73e110e..9e411d6 100644 --- a/packages/@js-utils-kit/object/tsconfig.json +++ b/packages/@js-utils-kit/object/tsconfig.json @@ -1,8 +1 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, - "include": ["src", "test"] -} +{ "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/pm/tsconfig.json b/packages/@js-utils-kit/pm/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/pm/tsconfig.json +++ b/packages/@js-utils-kit/pm/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/string/tsconfig.json b/packages/@js-utils-kit/string/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/string/tsconfig.json +++ b/packages/@js-utils-kit/string/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/system/tsconfig.json b/packages/@js-utils-kit/system/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/system/tsconfig.json +++ b/packages/@js-utils-kit/system/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/@js-utils-kit/types/src/PlainObject.ts b/packages/@js-utils-kit/types/src/PlainObject.ts new file mode 100644 index 0000000..f608168 --- /dev/null +++ b/packages/@js-utils-kit/types/src/PlainObject.ts @@ -0,0 +1,2 @@ +/** Represents a generic JavaScript object with arbitrary property keys */ +export type PlainObject = Record; diff --git a/packages/@js-utils-kit/types/src/index.ts b/packages/@js-utils-kit/types/src/index.ts index 676ec91..26ccaf1 100644 --- a/packages/@js-utils-kit/types/src/index.ts +++ b/packages/@js-utils-kit/types/src/index.ts @@ -13,4 +13,5 @@ export * from './Hour'; export * from './HttpStatusCode'; export * from './MinuteOrSecond'; export * from './PackageJson'; +export * from './PlainObject'; export * from './Trim'; diff --git a/packages/@js-utils-kit/types/tsconfig.json b/packages/@js-utils-kit/types/tsconfig.json index 98e3cfe..9b376c2 100644 --- a/packages/@js-utils-kit/types/tsconfig.json +++ b/packages/@js-utils-kit/types/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src"] } diff --git a/packages/@js-utils-kit/valid/tsconfig.json b/packages/@js-utils-kit/valid/tsconfig.json index 73e110e..1d7a4c2 100644 --- a/packages/@js-utils-kit/valid/tsconfig.json +++ b/packages/@js-utils-kit/valid/tsconfig.json @@ -1,8 +1,4 @@ { - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "composite": true, - "rootDir": "src" - }, + "extends": "../../../tsconfig.json", "include": ["src", "test"] } diff --git a/packages/js-utils-kit/tsconfig.json b/packages/js-utils-kit/tsconfig.json index 564a599..596e2cf 100644 --- a/packages/js-utils-kit/tsconfig.json +++ b/packages/js-utils-kit/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../tsconfig.json", "include": ["src"] } diff --git a/packages/juk-cli/tsconfig.json b/packages/juk-cli/tsconfig.json index 564a599..596e2cf 100644 --- a/packages/juk-cli/tsconfig.json +++ b/packages/juk-cli/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../tsconfig.json", "include": ["src"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ac1afa..dcfe127 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,7 +197,14 @@ importers: specifier: workspace:* version: link:../types - packages/@js-utils-kit/object: {} + packages/@js-utils-kit/object: + dependencies: + '@js-utils-kit/types': + specifier: workspace:* + version: link:../types + '@js-utils-kit/valid': + specifier: workspace:* + version: link:../valid packages/@js-utils-kit/pm: dependencies: diff --git a/scripts/exports.js b/scripts/exports.js index ce8c921..4e3e3cd 100644 --- a/scripts/exports.js +++ b/scripts/exports.js @@ -10,7 +10,7 @@ const posix = (p) => p.split(sep).join('/'); const rel = (p) => posix(relative(ROOT, p)); const project = new Project({ - tsConfigFilePath: join(ROOT, 'tsconfig.base.json'), + tsConfigFilePath: join(ROOT, 'tsconfig.json'), skipAddingFilesFromTsConfig: true, manipulationSettings: { quoteKind: QuoteKind.Single }, }); diff --git a/scripts/new-project.js b/scripts/new-project.js index 777d498..c4a7e54 100644 --- a/scripts/new-project.js +++ b/scripts/new-project.js @@ -98,11 +98,7 @@ async function createLibrary() { join(folder, 'tsconfig.json'), JSON.stringify( { - extends: '../../../tsconfig.base.json', - compilerOptions: { - composite: true, - rootDir: 'src', - }, + extends: '../../../tsconfig.json', include: ['src', 'test'], }, null, diff --git a/tsconfig.base.json b/tsconfig.json similarity index 100% rename from tsconfig.base.json rename to tsconfig.json