diff --git a/.gitignore b/.gitignore index 30d1d94..9291cd8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ node_modules /build +*.tsbuildinfo +graphify-out +.claude # environment variables .env .development.env -*.tsbuildinfo # Logs logs diff --git a/CHANGELOG.md b/CHANGELOG.md index a0202b6..68e801a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ ## Changelog -### [v2.3.1](https://github.com/panates/jsopen-objects/compare/v2.3.0...v2.3.1) - +### [v2.3.3](https://github.com/panates/jsopen-objects/compare/v2.3.2...v2.3.3) - + +#### πŸ“– Documentation Changes + +- docs: add TSDoc to every exported function @Eray Hanoğlu diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a00a170 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +## graphify + +This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. + +Rules: +- For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. +- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. +- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. +- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost). + + +## API docs baseline (docs/api.md, docs/api/*.md) + +`docs/api.md` starts with an HTML comment block (`docs-baseline`) recording the git commit, +package version, and date the API docs were last verified against source - see that block for +the exact format and the `git diff ..HEAD -- src/` command it documents. + +Rules: +- Whenever you write or update these API docs, record (or update) that baseline block with the + commit you verified against - so a later session can diff from a known point instead of + re-reading everything from scratch. +- Before trusting/updating the docs, diff `src/` (and `test/**/*.spec.ts` for examples) between + the recorded commit and `HEAD` to see what actually changed, then update only the affected + doc section(s) - don't regenerate everything unless the diff is broad enough to warrant it. +- After updating, bump `git-commit`/`package-version`/`date` in the baseline block to the new + `HEAD` (only once the docs are verified accurate as of that commit). diff --git a/README.md b/README.md index 61e7b55..965897b 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,54 @@ [![CI Tests][ci-test-image]][ci-test-url] [![Test Coverage][coveralls-image]][coveralls-url] -A 'swiss army knife' solution for working with javascript objects. +A 'swiss army knife' solution for working with JavaScript objects and arrays β€” deep merging, +cloning, omitting keys/nullish values, and a set of dependency-free type guards, all in one +small, fully-typed package. -## Functions +## Features + +- **`merge`** β€” deep or shallow merging of objects/arrays, multiple sources, array merge + strategies (append/unique), property-descriptor cloning, custom filters, and built-in + prototype-pollution protection. +- **`clone` / `deepClone`** β€” copy objects and arrays (including class instances with + `deepClone`), with full support for top-level arrays as the value being cloned. +- **`omit` / `omitUndefined` / `omitNull` / `omitNullish`** β€” drop specific keys or + `null`/`undefined` values, shallow or deep, from objects or arrays. +- **Type guards** β€” `isObject`, `isPlainObject`, `isBuiltIn`, `isConstructor`, `isIterable`, + `isAsyncIterable` β€” none of them throw on unexpected input. +- **`updateErrorMessage`** β€” change an `Error`'s message and keep its stack trace header + consistent, without losing the original stack frames. +- Zero runtime dependencies, ESM-only, written in TypeScript. + +## Quick Start + +```typescript +import { merge, clone, deepClone, omit, omitUndefined } from '@jsopen/objects'; + +// Deep merge +merge({ a: 1 }, { b: 2 }, { deep: true }); +// => { a: 1, b: 2 } + +// Clone (objects or top-level arrays, deeply by default) +clone({ a: 1, b: { c: 2 } }); +clone([1, 2, { x: 1 }]); + +// Deep-clone including class instances +class Point { constructor(public x: number, public y: number) {} } +deepClone({ point: new Point(1, 2) }); + +// Exclude keys / nullish values +omit({ a: 1, b: 2, c: 3 }, ['b']); // => { a: 1, c: 3 } +omitUndefined({ a: 1, b: undefined }, true); // => { a: 1 } +``` + +## Documentation + +See the [**Full API Reference**](docs/api.md) for every function, option, and edge case, or +jump to a focused guide: ### [merge](docs/merge.md) -Is a powerful, flexible tool for merging objects, arrays, and their nested properties. +A powerful, flexible tool for merging objects, arrays, and their nested properties. ### [clone / deepClone](docs/clone.md) Easy ways to create shallow or deep copies of objects and arrays. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..86e7922 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,456 @@ + + +# API Reference + +Complete reference for every function exported by `@jsopen/objects`. Each section documents +the full signature, options, return value, and edge-case behavior as implemented in `src/`. + +For narrower, example-heavy guides, see the topic docs instead: + +- [merge](merge.md) +- [clone / deepClone](clone.md) +- [omit / omitUndefined / omitNull / omitNullish](omit.md) +- [Type guard utilities](utils.md) +- [updateErrorMessage](update-error-message.md) + +## Table of Contents + +- [merge](#merge) +- [clone](#clone) +- [deepClone](#deepclone) +- [omit](#omit) +- [omitUndefined](#omitundefined) +- [omitNull](#omitnull) +- [omitNullish](#omitnullish) +- [isObject](#isobject) +- [isPlainObject](#isplainobject) +- [isBuiltIn](#isbuiltin) +- [isConstructor](#isconstructor) +- [isIterable](#isiterable) +- [isAsyncIterable](#isasynciterable) +- [updateErrorMessage](#updateerrormessage) + +--- + +## merge + +```typescript +function merge( + target: A, + source: B, + options?: merge.Options, +): A & B; + +// Overloads also accept a tuple of 2-4 sources, merged left to right +// with full type inference: +function merge( + target: A, + source: [B, C], + options?: merge.Options, +): A & B & C; +``` + +Merges the enumerable own properties of `source` into `target` and returns `target`. + +### Parameters + +| Parameter | Type | Description | +|:----------|:----------------------------|:-----------------------------------------------------------------------------| +| `target` | `object \| Function \| any[]` | The object (or array) properties are copied into. Mutated in place. | +| `source` | `object \| Function \| any[]` | The value to copy from, or a **tuple of sources** merged sequentially. | +| `options` | `merge.Options` | Optional. See [Options](#options) below. | + +- If `source` is `null` or `undefined`, `merge` is a no-op and returns `target` unchanged. +- If `source` is an array, it is treated as **multiple sources** to merge into `target` one + after another (`merge(target, [a, b])` is equivalent to merging `a` then `b`). This is the + one case where `merge()` does not treat `source` as a single array *value* β€” use + [`clone`](#clone) if you need to copy an array value itself. +- Throws `TypeError` if `target` is not an object, function, or array. +- Throws `TypeError` if `source` is neither `null`/`undefined`, an object, a function, nor an + array. + +### Basic usage + +```typescript +import { merge } from '@jsopen/objects'; + +const target = { a: 1, b: 2 }; +merge(target, { b: 3, c: 4 }); +// target is now: { a: 1, b: 3, c: 4 } + +// Multiple sources, merged left to right +merge({ a: 1 }, [{ b: 2 }, { c: 3 }]); +// => { a: 1, b: 2, c: 3 } +``` + +### Options + +| Option | Type | Default | Description | +|:------------------|:------------------------------------|:--------|:------------------------------------------------------------------------------------| +| `deep` | `boolean \| 'full' \| CallbackFn` | `false` | Enables deep merging. `true` recurses into plain objects/arrays only; `'full'` recurses into all objects except built-ins (`Date`, `RegExp`, `Map`, etc.); a callback decides per-path. | +| `mergeArrays` | `boolean \| 'unique' \| CallbackFn` | `false` | When deep, controls whether a source array is *appended* to the target array (`true`), appended with duplicates removed (`'unique'`), or a callback decides per-path. When falsy, the source array replaces (deep: clones) the target array. | +| `keepExisting` | `boolean \| CallbackFn` | `false` | If truthy, existing own properties of `target` are left untouched instead of being overwritten. | +| `copyDescriptors` | `boolean` | `false` | Copies the full property descriptor (getter/setter, `writable`, `enumerable`, `configurable`) instead of just the value. Getters/setters are always copied as accessors regardless of `deep`. | +| `symbolKeys` | `boolean` | `true` | Whether symbol-keyed properties of `source` are also copied. | +| `ignoreUndefined` | `boolean` | `true` | Skips source properties whose value is `undefined`. | +| `ignoreNulls` | `boolean` | `false` | Skips source properties whose value is `null`. | +| `ignoreSource` | `CallbackFn` | - | Called as `(value, ctx) => boolean` per source property; return `true` to skip it entirely (evaluated before `filter`). | +| `filter` | `CallbackFn` | - | Called as `(value, ctx) => boolean` per property; return `false` to exclude it from the result. | + +`CallbackFn` is `(value: any, ctx: CallbackContext) => boolean`, where `ctx` is: + +```typescript +interface CallbackContext { + source: any; + target: any; + key: string | symbol | number; + path: string; // dot/bracket path, e.g. "user.tags[0]" +} +``` + +### Deep merging + +```typescript +merge(target, source, { deep: true }); // plain objects & arrays only +merge(target, source, { deep: 'full' }); // class instances too (not Date/RegExp/Map/...) +merge(target, source, { + deep: (val, { path }) => path.startsWith('metadata'), +}); +``` + +When `deep` is enabled, nested plain objects/arrays (or, with `'full'`, any non-built-in object) +are recursively merged instead of replacing the target's value by reference; array values are +deep-cloned (see [Array handling](#array-handling) below) unless `mergeArrays` says otherwise. + +### Array handling + +- **Not deep**: the source array *replaces* the target's value by reference (no cloning). +- **Deep, `mergeArrays` falsy**: the source array is deep-cloned (nested objects/arrays cloned, + built-ins like `Date` kept by reference) and replaces the target's value. Any extra + non-index properties set on the source array (e.g. `arr.foo = 'x'`) are preserved on the clone. +- **Deep, `mergeArrays: true`**: the cloned source array elements are appended after the + target array's existing elements. +- **Deep, `mergeArrays: 'unique'`**: same as above, then deduplicated with `Set` semantics + (`SameValueZero`). +- **A top-level array as `source`** (i.e. the whole value passed to `merge`/`clone`/`omit*`, not + a property of it) is copied element-by-element into `target` rather than being flattened into + `{0: ..., 1: ..., length: ...}` β€” `target` should itself be an array-like value in that case. + `clone`/`omit`/`omitUndefined`/`omitNull`/`omitNullish` all set this up for you automatically. + +```typescript +const target = { tags: ['js'] }; +merge(target, { tags: ['ts', 'js'] }, { deep: true, mergeArrays: 'unique' }); +// target.tags is now: ['js', 'ts'] +``` + +### Property descriptors + +```typescript +const source = { get id() { return Math.random(); } }; +merge({}, source, { copyDescriptors: true }); +// result keeps the 'id' getter instead of resolving it to a static value +``` + +### Filtering and ignoring + +```typescript +merge(target, source, { + ignoreSource: (val) => typeof val === 'boolean', // drop booleans from source entirely + filter: (val, { key }) => key !== 'internalSecret', // drop a specific key + keepExisting: true, // never overwrite what target already has +}); +``` + +### Security + +`merge` always skips the `__proto__` and `constructor` keys on every source object, at every +depth, to prevent prototype-pollution regardless of `options`. + +--- + +## clone + +```typescript +function clone(obj: T, options?: merge.Options): T; +``` + +Creates a copy of `obj` by merging it onto a fresh, empty target (`{}` for objects, `[]` when +`obj` is itself an array). Internally calls `merge` with `deep: true` by default β€” plain +objects and arrays are recursively copied; class instances and built-ins (`Date`, `RegExp`, +`Map`, etc.) are assigned by reference unless you pass `deep: 'full'`. + +Accepts the same `options` as [`merge`](#merge) (`deep` defaults to `true` instead of `false`). + +```typescript +import { clone } from '@jsopen/objects'; + +const original = { a: 1, b: { c: 2 } }; +const copy = clone(original); +copy.b.c = 3; +original.b.c; // 2 β€” unaffected + +// Top-level arrays are cloned too (including class instances, extra +// non-index properties, and symbol keys on the array) +clone([1, 2, { x: 1 }]); // => a new, independent array +``` + +## deepClone + +```typescript +function deepClone( + obj: T, + options?: StrictOmit, +): T; +``` + +Same as `clone`, but forces `deep: 'full'`: class instances nested anywhere in `obj` are +recursively cloned too (built-ins such as `Date`/`RegExp`/`Map`/`Set` are still assigned by +reference, per [`isBuiltIn`](#isbuiltin)). `deep` cannot be overridden via `options`. + +```typescript +import { deepClone } from '@jsopen/objects'; + +class Point { x = 1; } +const original = { p: new Point() }; +const copy = deepClone(original); +copy.p.x = 2; +original.p.x; // 1 β€” the class instance was cloned, not referenced +``` + +--- + +## omit + +```typescript +function omit(obj: T, keys: K[]): Omit; +``` + +Returns a new object (or array, if `obj` is an array) with every own property of `obj` except +those listed in `keys`. This is a **shallow** operation (`deep: false`); nested values are +copied by reference, not cloned. + +```typescript +import { omit } from '@jsopen/objects'; + +const original = { a: 1, b: 2, c: 3 }; +omit(original, ['b', 'c']); // => { a: 1 } +``` + +## omitUndefined + +```typescript +function omitUndefined(obj: T, deep: true): DeepOmitUndefined; +function omitUndefined(obj: T, deep: 'full'): DeeperOmitUndefined; +function omitUndefined(obj: T, deep?: false): OmitUndefined; +``` + +Returns a copy of `obj` with every property whose value is `undefined` removed. + +| `deep` | Behavior | +|:--------------|:----------------------------------------------------------------------------| +| `false` (default) | Only top-level `undefined` properties are removed. | +| `true` | Recurses into plain objects and arrays, removing `undefined` at every level. | +| `'full'` | Same as `true`, but also recurses into class instances. | + +Property descriptors are preserved (`copyDescriptors: true` internally), so getters/setters on +`obj` survive the copy. + +```typescript +import { omitUndefined } from '@jsopen/objects'; + +omitUndefined({ a: '1', b: undefined, c: { d: undefined } }, true); +// => { a: '1', c: {} } +``` + +## omitNull + +```typescript +function omitNull(obj: T, deep: true): DeepOmitTypes; +function omitNull(obj: T, deep: 'full'): DeeperUnNullish; +function omitNull(obj: T, deep?: false): OmitTypes; +``` + +Same as `omitUndefined`, but removes properties whose value is `null` instead (and, unlike +`omitUndefined`/`omitNullish`, does **not** ignore `undefined` values β€” they are kept as-is). + +```typescript +import { omitNull } from '@jsopen/objects'; + +omitNull({ a: 1, b: null, c: { d: null } }, true); // => { a: 1, c: {} } +``` + +## omitNullish + +```typescript +function omitNullish(obj: T, deep: true): DeepUnNullish; +function omitNullish(obj: T, deep: 'full'): DeeperUnNullish; +function omitNullish(obj: T, deep?: false): UnNullish; +``` + +Combines `omitUndefined` and `omitNull`: removes properties whose value is either `null` or +`undefined`. + +```typescript +import { omitNullish } from '@jsopen/objects'; + +omitNullish({ a: 1, b: null, c: undefined, d: { e: null } }, true); +// => { a: 1, d: {} } +``` + +> All four `omit*` functions accept a top-level array as `obj` β€” the result is a new array of +> the same shape, with the same filtering rules applied to any plain-object elements. + +--- + +## isObject + +```typescript +function isObject(v: any): boolean; +``` + +Returns `true` if `v` is non-null, `typeof v === 'object'`, and **not** an array. Arrays, +`null`, and primitives all return `false`. + +```typescript +import { isObject } from '@jsopen/objects'; + +isObject({}); // true +isObject(new Date()); // true +isObject([]); // false +isObject(null); // false +``` + +## isPlainObject + +```typescript +function isPlainObject(obj: any): boolean; +``` + +Returns `true` only for objects created by `{}`, `new Object()`, or `Object.create(null)`-style +plain prototypes β€” i.e. objects whose constructor is the built-in `Object`. Class instances, +arrays, and built-ins return `false`. + +```typescript +import { isPlainObject } from '@jsopen/objects'; + +isPlainObject({}); // true +isPlainObject(new (class {})()); // false +isPlainObject([]); // false +``` + +## isBuiltIn + +```typescript +function isBuiltIn(v: any): boolean; +``` + +Returns `true` for arrays and recognized built-in JavaScript object types: `Date`, `RegExp`, +`Map`, `Set`, `WeakMap`, `WeakSet`, `WeakRef`, `Promise`, `Error` (and subclasses), `ArrayBuffer`, +`SharedArrayBuffer`, every typed array (`Uint8Array`, `Int32Array`, ...), and Node's `Buffer`. +Plain objects and class instances return `false`. + +```typescript +import { isBuiltIn } from '@jsopen/objects'; + +isBuiltIn(new Date()); // true +isBuiltIn([1, 2]); // true +isBuiltIn({}); // false +``` + +## isConstructor + +```typescript +function isConstructor(fn: any): fn is Type; +``` + +Returns `true` if `fn` is a function whose `prototype.constructor` is itself, with a real +(non-empty, non-`"Function"`) name β€” i.e. a `class` or a traditional constructor function. +Arrow functions and plain functions without their own named prototype return `false`. + +```typescript +import { isConstructor } from '@jsopen/objects'; + +isConstructor(class Foo {}); // true +isConstructor(function Foo() {}); // true +isConstructor(() => {}); // false +``` + +## isIterable + +```typescript +function isIterable(x: any): x is Iterable | IterableIterator; +``` + +Returns `true` if `x` is non-nullish and implements the `Symbol.iterator` protocol. Safe to +call with any value, including `null`, `undefined`, and primitives β€” it never throws. + +```typescript +import { isIterable } from '@jsopen/objects'; + +isIterable([]); // true +isIterable(new Set()); // true +isIterable('abc'); // true β€” strings are iterable +isIterable({}); // false +isIterable(null); // false β€” does not throw +``` + +## isAsyncIterable + +```typescript +function isAsyncIterable( + x: any, +): x is AsyncIterable | AsyncIterableIterator; +``` + +Same as `isIterable`, but checks for `Symbol.asyncIterator`. Also safe to call with any value. + +```typescript +import { isAsyncIterable } from '@jsopen/objects'; + +const asyncGen = async function* () {}; +isAsyncIterable(asyncGen()); // true +isAsyncIterable([]); // false +isAsyncIterable(null); // false β€” does not throw +``` + +--- + +## updateErrorMessage + +```typescript +function updateErrorMessage(err: Error, newMessage: string): Error; +``` + +Updates `err.message` **and** rewrites `err.stack` so its header line(s) reflect the new +message, while the original stack frames (the `at ...` lines) are left untouched. Returns the +same `err` instance (mutated in place). + +Behavior: +- `err.message` is set to `String(newMessage)`. +- If `err.stack` isn't a string, only the message is updated and `err` is returned as-is. +- Otherwise, the stack is split into lines and the first line matching a stack-frame pattern + (`/^\s*at\s+/`) is located. Every line before it (the old `"Name: message"` header, which may + span multiple lines for multi-line messages) is replaced with `${err.name}: ${newMessage}` + (also split across lines if `newMessage` contains newlines); every line from the first frame + onward is preserved as-is. +- If no frame line is found (stack format not recognized), the new header lines are inserted in + place of the first line instead. + +```typescript +import { updateErrorMessage } from '@jsopen/objects'; + +const err = new Error('Original message'); +updateErrorMessage(err, 'Updated message'); + +err.message; // "Updated message" +err.stack; // starts with "Error: Updated message", original frames intact +``` + +Useful when re-throwing or wrapping an error: changing `err.message` alone leaves the old +message baked into the first line of `err.stack` on every engine, which this function fixes +without discarding where the error actually originated. diff --git a/docs/clone.md b/docs/clone.md index 56272d3..eaee50f 100644 --- a/docs/clone.md +++ b/docs/clone.md @@ -79,3 +79,18 @@ const original = { const copy = clone(original, { copyDescriptors: true }); // copy now has the 'id' getter. ``` + +### Cloning Top-Level Arrays + +`obj` may be an array itself, not just a plain object β€” `clone`/`deepClone` create a fresh +array in that case and copy elements (and any extra non-index properties or symbol keys set on +the array) into it, applying the same deep-clone rules as a nested array would get. + +```typescript +const original = [1, 2, { nested: true }]; +const copy = clone(original); +copy[2].nested = false; +original[2].nested; // true β€” unaffected +``` + +See [merge's Array handling](api.md#array-handling) for the full rules. diff --git a/docs/omit.md b/docs/omit.md index 0cb52ae..8301e82 100644 --- a/docs/omit.md +++ b/docs/omit.md @@ -67,3 +67,15 @@ const result = omitNullish(original, true); ## Options These functions use standard [`merge`](merge.md) options internally. `omitUndefined`, `omitNull`, and `omitNullish` preserve property descriptors by default (`copyDescriptors: true`). + +## Top-Level Arrays + +`obj` may also be an array β€” each function then returns a new array of the same shape, with +the same filtering rules applied to any plain-object elements it contains. + +```typescript +import { omitUndefined } from '@jsopen/objects'; + +const result = omitUndefined([{ a: 1, b: undefined }, { c: 2 }], true); +// result is [{ a: 1 }, { c: 2 }] +``` diff --git a/docs/update-error-message.md b/docs/update-error-message.md index 8fef344..6956208 100644 --- a/docs/update-error-message.md +++ b/docs/update-error-message.md @@ -6,10 +6,15 @@ The `updateErrorMessage` function updates an `Error` object's message and refres ### `updateErrorMessage(err, newMessage)` -Updates the message of the error and captures a new stack trace. - -- **V8 Engines (Node.js, Chrome)**: Uses `Error.captureStackTrace` for optimal performance and accuracy. -- **Other Engines**: Provides a fallback mechanism that manually reconstructs the stack trace with the new message while preserving original stack frames. +Updates the message of the error and rewrites the header of its stack trace to match, on every +engine. + +- Sets `err.message` to the new message. +- Locates the first stack-frame line (matching `at ...`) in `err.stack` and replaces everything + before it β€” the old `"Name: message"` header β€” with `"${err.name}: ${newMessage}"`, keeping + every original frame line untouched. +- If `err.stack` isn't a string, or no frame line can be found, it falls back to just replacing + the first line(s) with the new message. ```typescript import { updateErrorMessage } from '@jsopen/objects'; diff --git a/package-lock.json b/package-lock.json index bdb4ad2..918c223 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jsopen/objects", - "version": "2.3.1", + "version": "2.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jsopen/objects", - "version": "2.3.1", + "version": "2.3.3", "license": "MIT", "dependencies": { "tslib": "^2.8.1" diff --git a/package.json b/package.json index 86cede2..e3e2804 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@jsopen/objects", "description": "Helper utilities for working with JavaScript objects and arrays", - "version": "2.3.1", + "version": "2.3.3", "author": "Panates", "license": "MIT", "private": true, @@ -46,7 +46,8 @@ "precitest": "rimraf coverage", "citest": "c8 mocha", "qc": "npm run lint && npm run check", - "version": "auto-changelog -p --starting-version v4.0.0 && git add CHANGELOG.md" + "version": "auto-changelog -p --starting-version v4.0.0 && git add CHANGELOG.md", + "graph": "graphify update ." }, "type": "module", "module": "./index.js", diff --git a/src/clone.ts b/src/clone.ts index 7518240..06dcad3 100644 --- a/src/clone.ts +++ b/src/clone.ts @@ -1,13 +1,55 @@ import type { StrictOmit } from 'ts-gems'; -import { merge } from './merge.js'; +import { merge, mergeSingle } from './merge.js'; +/** + * Creates a copy of `obj`. + * + * By default this is a **deep** clone (`deep: true`): nested plain objects + * and arrays are recursively copied, while class instances and built-ins + * (`Date`, `RegExp`, `Map`, ...) are assigned by reference unless + * `options.deep` is set to `'full'`. `obj` may itself be a top-level array, + * in which case a new, independent array is returned. + * + * Accepts the same `options` as {@link merge} (`deep` defaults to `true` + * here instead of `false`). + * + * @param obj - The object or array to copy. + * @param options - See {@link merge.Options}. + * @returns A new object (or array) with `obj`'s properties copied into it. + * @example + * const original = { a: 1, b: { c: 2 } }; + * const copy = clone(original); + * copy.b.c = 3; + * original.b.c; // 2 β€” unaffected + * + * clone([1, 2, { x: 1 }]); // top-level arrays are cloned too + */ export function clone(obj: T, options?: merge.Options): T { - return merge({} as T, obj, { + const target = (Array.isArray(obj) ? [] : {}) as T; + return mergeSingle(target, obj, { ...options, deep: options?.deep ?? true, }); } +/** + * Creates a full deep copy of `obj`, including class instances. + * + * Equivalent to {@link clone} with `deep: 'full'` forced: every non-built-in + * object nested anywhere in `obj` is recursively cloned (built-ins such as + * `Date`/`RegExp`/`Map`/`Set`, per {@link isBuiltIn}, are still assigned by + * reference). `deep` cannot be overridden via `options`. + * + * @param obj - The object or array to copy. + * @param options - See {@link merge.Options} (all options except `deep`). + * @returns A new, fully independent deep copy of `obj`. + * @example + * class Point { x = 1; } + * const original = { p: new Point() }; + * const copy = deepClone(original); + * copy.p.x = 2; + * original.p.x; // 1 β€” the class instance was cloned, not referenced + */ export function deepClone( obj: T, options?: StrictOmit, diff --git a/src/index.ts b/src/index.ts index 7aaefe6..76b7c36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export * from './clone.js'; export * from './is-object.js'; -export * from './merge.js'; +export { merge } from './merge.js'; export * from './omit.js'; export * from './type-guards.js'; export { updateErrorMessage } from './update-error-message.js'; diff --git a/src/is-object.ts b/src/is-object.ts index 6e2be28..08ad17c 100644 --- a/src/is-object.ts +++ b/src/is-object.ts @@ -1,9 +1,39 @@ const objCtorStr = Function.prototype.toString.call(Object); +/** + * Checks whether a value is a non-null object that is not an array. + * + * Unlike a plain `typeof v === 'object'` check, this excludes both `null` + * (which also has `typeof null === 'object'`) and arrays. + * + * @param v - The value to test. + * @returns `true` if `v` is a non-null, non-array object (including class + * instances and built-ins such as `Date`); `false` otherwise. + * @example + * isObject({}); // true + * isObject(new Date()); // true + * isObject([]); // false + * isObject(null); // false + */ export function isObject(v: any): boolean { - return v && typeof v === 'object' && !Array.isArray(v); + return !!v && typeof v === 'object' && !Array.isArray(v); } +/** + * Checks whether a value is a "plain" object β€” one created by `{}`, + * `new Object()`, or with a `null` prototype (e.g. `Object.create(null)`). + * + * Class instances, arrays, and built-ins (`Date`, `RegExp`, `Map`, ...) + * all return `false`. + * + * @param obj - The value to test. + * @returns `true` if `obj` is a plain object. + * @example + * isPlainObject({}); // true + * isPlainObject(Object.create(null)); // true + * isPlainObject(new (class {})()); // false + * isPlainObject([]); // false + */ export function isPlainObject(obj: any): boolean { if ( obj && diff --git a/src/merge.ts b/src/merge.ts index dbc95d5..1a23cf4 100644 --- a/src/merge.ts +++ b/src/merge.ts @@ -3,17 +3,41 @@ import { isBuiltIn } from './type-guards.js'; const hasOwnProperty = Object.prototype.hasOwnProperty; +/** + * Merges two sources into `target`, one after another (later sources take + * precedence). See the general {@link merge} overload below for full details. + * @param target - Object properties are copied into; mutated in place. + * @param source - A tuple of sources, merged left to right. + * @param options - See {@link merge.Options}. + * @returns `target`. + */ export function merge( target: A, source: [B, C], options?: merge.Options, ): A & B & C; +/** + * Merges three sources into `target`, one after another (later sources + * take precedence). See the general {@link merge} overload below for full details. + * @param target - Object properties are copied into; mutated in place. + * @param source - A tuple of sources, merged left to right. + * @param options - See {@link merge.Options}. + * @returns `target`. + */ export function merge< A extends object, B extends object, C extends object, D extends object, >(target: A, source: [B, C, D], options?: merge.Options): A & B & C & D; +/** + * Merges four sources into `target`, one after another (later sources take + * precedence). See the general {@link merge} overload below for full details. + * @param target - Object properties are copied into; mutated in place. + * @param source - A tuple of sources, merged left to right. + * @param options - See {@link merge.Options}. + * @returns `target`. + */ export function merge< A extends object, B extends object, @@ -21,11 +45,42 @@ export function merge< D extends object, E extends object, >(target: A, source: [B, C, D, E], options?: merge.Options): A & B & C & D & E; +/** + * Merges four sources into `target`, one after another (later sources take + * precedence). See the general {@link merge} overload below for full details. + * @param target - Object properties are copied into; mutated in place. + * @param source - A tuple of sources, merged left to right. + * @param options - See {@link merge.Options}. + * @returns `target`. + */ export function merge( target: A, source: [B, C, D, F], options?: merge.Options, ): A & B & C & D & F; +/** + * Merges the enumerable own properties of `source` into `target` and + * returns `target`. + * + * If `source` is an array, it is treated as **multiple sources** to merge + * into `target` one after another β€” use {@link clone} if you need to copy + * an array *value* itself rather than merge a list of sources. + * If `source` is `null` or `undefined`, this is a no-op. + * + * @param target - The object (or array) properties are copied into. Mutated in place. + * @param source - The value to copy from, or an array of sources merged sequentially. + * @param options - See {@link merge.Options}. + * @returns `target`, now containing the merged properties. + * @throws {TypeError} If `target` is not an object, function, or array. + * @throws {TypeError} If `source` is neither nullish, an object, a function, nor an array. + * @example + * const target = { a: 1, b: 2 }; + * merge(target, { b: 3, c: 4 }); + * // target is now: { a: 1, b: 3, c: 4 } + * + * merge({ a: 1 }, [{ b: 2 }, { c: 3 }]); + * // => { a: 1, b: 2, c: 3 } + */ export function merge( target: A, source: B, @@ -36,7 +91,11 @@ export function merge( sourceObject: any, options?: merge.Options, ): any { - if (!(isObject(targetObject) || typeof targetObject === 'function')) { + if (!( + isObject(targetObject) || + typeof targetObject === 'function' || + Array.isArray(targetObject) + )) { throw new TypeError('"target" argument must be an object'); } if (sourceObject == null) return targetObject; @@ -46,7 +105,7 @@ export function merge( Array.isArray(sourceObject) )) { throw new TypeError( - '"target" argument must be an object or array of objects', + '"source" argument must be an object or array of objects', ); } const optsKeepExisting = !!options?.keepExisting; @@ -70,6 +129,21 @@ export function merge( : undefined; const _merge = (target: any, source: any, parentPath: string = '') => { + /** A source that is itself an array (not a property holding an array) + * must be cloned/copied as an array value, not unpacked or treated as a + * plain object (which would drop it to `{0: ..., 1: ..., length: ...}`). */ + if (Array.isArray(source)) { + const isDeepArr = optsDeep === true || optsDeepFull; + const src2 = isDeepArr ? _arrayClone(source, parentPath) : source; + const arrKeys: (string | symbol)[] = Object.getOwnPropertyNames(src2); + if (options?.symbolKeys ?? true) + arrKeys.push(...Object.getOwnPropertySymbols(src2)); + for (const k of arrKeys) { + if (k === 'length') continue; + (target as any)[k] = (src2 as any)[k]; + } + return target; + } if (!isObject(source)) return; const keys: (string | symbol)[] = Object.getOwnPropertyNames(source); if (options?.symbolKeys ?? true) @@ -253,35 +327,72 @@ export function merge( return out; }; - const sources = Array.isArray(sourceObject) ? sourceObject : [sourceObject]; + const noArrayUnpack = !!(options as any)?.__noArrayUnpack; + const sources = + !noArrayUnpack && Array.isArray(sourceObject) + ? sourceObject + : [sourceObject]; for (const src of sources) { _merge(targetObject, src); } return targetObject; } +/** + * Merges a single `source` value into `target`, treating `source` as one + * value even when it is an array β€” unlike `merge()`, it never unpacks a + * top-level array source into multiple sequential sources. + * Used internally by `clone()` and the `omit*()` helpers, whose `obj` + * argument is always a single value that may itself be an array. + * @internal + */ +export function mergeSingle( + target: T, + source: any, + options?: merge.Options, +): T { + return merge(target, source, { + ...options, + __noArrayUnpack: true, + } as any); +} + const NUMBER_PATTERN = /^\d+$/; /** + * Types used by {@link merge}'s options and callbacks β€” also reused by + * {@link clone}, {@link deepClone}, and the `omit*` helpers. * @namespace */ export namespace merge { + /** + * A callback used by several {@link Options} to decide, per property, + * whether to include it, recurse into it, or keep merging it. Return + * `true` to proceed (go deep / keep the value / merge arrays); return + * `false` to skip. + */ export type CallbackFn = (value: any, ctx: CallbackContext) => boolean; + /** Context passed to a {@link CallbackFn} for the property currently being processed. */ export interface CallbackContext { + /** The source object (or array) the property belongs to. */ source: any; + /** The target object (or array) the property is being merged into. */ target: any; + /** The property key being processed. */ key: string | symbol | number; + /** Dot/bracket-notation path of the property, e.g. `"user.tags[0]"`. */ path: string; } + /** Options accepted by {@link merge}, {@link clone}, {@link deepClone}, and the `omit*` helpers. */ export interface Options { /** * Optional variable that determines the depth of an operation or inclusion behavior. * * - If set to `true`, it enables a deep operation for only plain objects and arrays. Non-plain objects (class instances) are assigned by reference. * - If set to `'full'`, it enables a deep operation for all objects, including classes, excluding built-in objects. - * - If assigned a `NodeCallback` function, it provides a custom callback mechanism for handling the operation. + * - If assigned a `CallbackFn`, it provides a custom callback mechanism for handling the operation. * * This variable can be used to define the level of depth or customization for a given process. * @default false diff --git a/src/omit.ts b/src/omit.ts index 65071a4..27fb2ca 100644 --- a/src/omit.ts +++ b/src/omit.ts @@ -8,14 +8,28 @@ import type { OmitUndefined, UnNullish, } from 'ts-gems'; -import { merge } from './merge.js'; +import { mergeSingle } from './merge.js'; +/** + * Returns a new object (or array, if `obj` is an array) containing every + * own property of `obj` except those listed in `keys`. + * + * This is a shallow operation β€” nested values are copied by reference, + * not cloned. + * + * @param obj - The object or array to copy from. + * @param keys - The keys to exclude from the result. + * @returns A new value with `obj`'s properties minus `keys`. + * @example + * omit({ a: 1, b: 2, c: 3 }, ['b', 'c']); // => { a: 1 } + */ export function omit( obj: T, keys: K[], ): Omit { const keysSet = new Set(keys); - return merge({}, obj, { + const target = (Array.isArray(obj) ? [] : {}) as any; + return mergeSingle(target, obj, { deep: false, filter(_, { key }) { return !keysSet.has(key); @@ -23,45 +37,85 @@ export function omit( }); } +/** Recursively removes `undefined` properties from plain objects and arrays. See {@link omitUndefined}. */ export function omitUndefined( obj: T, deep: true, ): DeepOmitUndefined; +/** Recursively removes `undefined` properties, including from class instances. See {@link omitUndefined}. */ export function omitUndefined( obj: T, deep: 'full', ): DeeperOmitUndefined; +/** Removes only top-level `undefined` properties. See {@link omitUndefined}. */ export function omitUndefined( obj: T, deep: false, ): OmitUndefined; +/** + * Returns a copy of `obj` (or array, if `obj` is an array) with every + * property whose value is `undefined` removed. + * + * Property descriptors are preserved, so getters/setters on `obj` survive + * the copy. + * + * @param obj - The object or array to copy from. + * @param deep - `false` (default) removes only top-level `undefined` + * properties; `true` recurses into plain objects/arrays; `'full'` also + * recurses into class instances. + * @returns A new value with `undefined` properties removed. + * @example + * omitUndefined({ a: '1', b: undefined, c: { d: undefined } }, true); + * // => { a: '1', c: {} } + */ export function omitUndefined(obj: T): OmitUndefined; export function omitUndefined( obj: T, deep?: boolean | 'full', ) { - return merge({}, obj, { + const target = (Array.isArray(obj) ? [] : {}) as any; + return mergeSingle(target, obj, { deep, ignoreUndefined: true, copyDescriptors: true, }); } +/** Recursively removes `null` properties from plain objects and arrays. See {@link omitNull}. */ export function omitNull( obj: T, deep: true, ): DeepOmitTypes; +/** Recursively removes `null` properties, including from class instances. See {@link omitNull}. */ export function omitNull( obj: T, deep: 'full', ): DeeperUnNullish; +/** Removes only top-level `null` properties. See {@link omitNull}. */ export function omitNull( obj: T, deep: false, ): OmitTypes; +/** + * Returns a copy of `obj` (or array, if `obj` is an array) with every + * property whose value is `null` removed. `undefined` values are kept + * as-is β€” use {@link omitNullish} to drop both. + * + * Property descriptors are preserved, so getters/setters on `obj` survive + * the copy. + * + * @param obj - The object or array to copy from. + * @param deep - `false` (default) removes only top-level `null` + * properties; `true` recurses into plain objects/arrays; `'full'` also + * recurses into class instances. + * @returns A new value with `null` properties removed. + * @example + * omitNull({ a: 1, b: null, c: { d: null } }, true); // => { a: 1, c: {} } + */ export function omitNull(obj: T): OmitTypes; export function omitNull(obj: T, deep?: boolean | 'full') { - return merge({}, obj, { + const target = (Array.isArray(obj) ? [] : {}) as any; + return mergeSingle(target, obj, { deep, ignoreNulls: true, ignoreUndefined: false, @@ -69,21 +123,42 @@ export function omitNull(obj: T, deep?: boolean | 'full') { }); } +/** Recursively removes `null`/`undefined` properties from plain objects and arrays. See {@link omitNullish}. */ export function omitNullish( obj: T, deep: true, ): DeepUnNullish; +/** Recursively removes `null`/`undefined` properties, including from class instances. See {@link omitNullish}. */ export function omitNullish( obj: T, deep: 'full', ): DeeperUnNullish; +/** Removes only top-level `null`/`undefined` properties. See {@link omitNullish}. */ export function omitNullish( obj: T, deep: false, ): UnNullish; +/** + * Returns a copy of `obj` (or array, if `obj` is an array) with every + * property whose value is either `null` or `undefined` removed. Combines + * {@link omitUndefined} and {@link omitNull}. + * + * Property descriptors are preserved, so getters/setters on `obj` survive + * the copy. + * + * @param obj - The object or array to copy from. + * @param deep - `false` (default) removes only top-level nullish + * properties; `true` recurses into plain objects/arrays; `'full'` also + * recurses into class instances. + * @returns A new value with nullish properties removed. + * @example + * omitNullish({ a: 1, b: null, c: undefined, d: { e: null } }, true); + * // => { a: 1, d: {} } + */ export function omitNullish(obj: T): UnNullish; export function omitNullish(obj: T, deep?: boolean | 'full') { - return merge({}, obj, { + const target = (Array.isArray(obj) ? [] : {}) as any; + return mergeSingle(target, obj, { deep, ignoreNulls: true, ignoreUndefined: true, diff --git a/src/type-guards.ts b/src/type-guards.ts index 130834d..bd44ad7 100644 --- a/src/type-guards.ts +++ b/src/type-guards.ts @@ -1,6 +1,24 @@ import { Buffer } from 'buffer'; import type { Type } from 'ts-gems'; +/** + * Checks whether a value is one of the recognized built-in JavaScript + * object types, or an array. + * + * Recognized types: `Array`, `Date`, `RegExp`, `Map`, `Set`, `WeakMap`, + * `WeakSet`, `WeakRef`, `Promise`, `Error` (and subclasses), `ArrayBuffer`, + * `SharedArrayBuffer`, every typed array, and Node's `Buffer`. + * + * Used internally by {@link merge} to decide which values should be + * assigned by reference instead of being deep-merged or cloned. + * + * @param v - The value to test. + * @returns `true` if `v` is an array or one of the recognized built-in types. + * @example + * isBuiltIn(new Date()); // true + * isBuiltIn([1, 2]); // true + * isBuiltIn({}); // false + */ export function isBuiltIn(v: any): boolean { return ( (v && @@ -29,8 +47,22 @@ export function isBuiltIn(v: any): boolean { ); } +/** + * Checks whether a value is a function that can be used as a constructor + * (a `class`, or a traditional named constructor function). + * + * Arrow functions and anonymous/plain functions that don't own their + * prototype's `constructor` return `false`. + * + * @param fn - The value to test. + * @returns `true` if `fn` is usable as a constructor. + * @example + * isConstructor(class Foo {}); // true + * isConstructor(function Foo() {}); // true + * isConstructor(() => {}); // false + */ export function isConstructor(fn: any): fn is Type { - return ( + return !!( typeof fn === 'function' && fn.prototype && fn.prototype.constructor === fn && @@ -39,14 +71,43 @@ export function isConstructor(fn: any): fn is Type { ); } +/** + * Checks whether a value implements the iterable protocol + * (`Symbol.iterator`). + * + * Safe to call with any value, including `null`, `undefined`, and + * primitives β€” it never throws. + * + * @param x - The value to test. + * @returns `true` if `x` is non-nullish and iterable. + * @example + * isIterable([]); // true + * isIterable(new Set()); // true + * isIterable('abc'); // true β€” strings are iterable + * isIterable(null); // false β€” does not throw + */ export function isIterable( x: any, ): x is Iterable | IterableIterator { - return Symbol.iterator in x; + return x != null && typeof x[Symbol.iterator] === 'function'; } +/** + * Checks whether a value implements the async-iterable protocol + * (`Symbol.asyncIterator`). + * + * Safe to call with any value, including `null`, `undefined`, and + * primitives β€” it never throws. + * + * @param x - The value to test. + * @returns `true` if `x` is non-nullish and async-iterable. + * @example + * const asyncGen = async function* () {}; + * isAsyncIterable(asyncGen()); // true + * isAsyncIterable([]); // false + */ export function isAsyncIterable( x: any, ): x is AsyncIterable | AsyncIterableIterator { - return Symbol.asyncIterator in x; + return x != null && typeof x[Symbol.asyncIterator] === 'function'; } diff --git a/src/update-error-message.ts b/src/update-error-message.ts index e364ec3..a84f7aa 100644 --- a/src/update-error-message.ts +++ b/src/update-error-message.ts @@ -1,7 +1,21 @@ /** - * Updates the error message and stack trace at sametime. - * @param err - * @param newMessage + * Updates an `Error`'s message and rewrites the header of its stack trace + * to match, on every engine, while leaving the original stack frames + * (the `at ...` lines) untouched. + * + * Useful when re-throwing or wrapping an error: changing `err.message` + * alone leaves the old message baked into the first line of `err.stack`, + * which this function fixes without discarding where the error actually + * originated. + * + * @param err - The error to update, mutated in place. + * @param newMessage - The new message to apply. + * @returns The same `err` instance, for convenient chaining. + * @example + * const err = new Error('Original message'); + * updateErrorMessage(err, 'Updated message'); + * err.message; // "Updated message" + * err.stack; // starts with "Error: Updated message" */ export function updateErrorMessage(err: Error, newMessage: string) { err.message = String(newMessage); diff --git a/test/clone.spec.ts b/test/clone.spec.ts index bedf5cb..5b62eca 100644 --- a/test/clone.spec.ts +++ b/test/clone.spec.ts @@ -1,4 +1,4 @@ -import { clone } from '@jsopen/objects'; +import { clone, deepClone } from '@jsopen/objects'; import { expect } from 'expect'; describe('clone', () => { @@ -30,4 +30,37 @@ describe('clone', () => { expect(JSON.stringify(o)).toStrictEqual(JSON.stringify(a)); expect(o.foo[extra]).toStrictEqual('abc'); }); + + it('should clone a top-level array', () => { + const a = [1, 2, 3]; + const o = clone(a); + expect(Array.isArray(o)).toBeTruthy(); + expect(o).toStrictEqual(a); + expect(o).not.toBe(a); + }); + + it('should deep clone a top-level array of objects', () => { + const a = [{ x: 1 }, { y: 2 }]; + const o = clone(a); + expect(o).toStrictEqual(a); + o[0].x = 99; + expect(a[0].x).toStrictEqual(1); + }); + + it('should deep clone a top-level array with deepClone', () => { + const a = [{ x: { y: 1 } }]; + const o = deepClone(a); + expect(o).toStrictEqual(a); + o[0].x.y = 99; + expect(a[0].x.y).toStrictEqual(1); + }); + + it('should preserve extra properties on a cloned top-level array', () => { + const a: any = [1, 2]; + a.extra = 'abc'; + const o: any = clone(a); + expect(Array.isArray(o)).toBeTruthy(); + expect(JSON.stringify(o)).toStrictEqual(JSON.stringify([1, 2])); + expect(o.extra).toStrictEqual('abc'); + }); }); diff --git a/test/is-async-iterable.spec.ts b/test/is-async-iterable.spec.ts new file mode 100644 index 0000000..cee5353 --- /dev/null +++ b/test/is-async-iterable.spec.ts @@ -0,0 +1,31 @@ +import { isAsyncIterable } from '@jsopen/objects'; +import { expect } from 'expect'; + +describe('isAsyncIterable', () => { + it('Should return true for async iterables', () => { + const asyncIterable = { + [Symbol.asyncIterator]() { + return this; + }, + next() { + return Promise.resolve({ value: undefined, done: true }); + }, + }; + expect(isAsyncIterable(asyncIterable)).toBeTruthy(); + }); + + it('Should return false for non async-iterables', () => { + expect(isAsyncIterable({})).not.toBeTruthy(); + expect(isAsyncIterable([])).not.toBeTruthy(); + expect(isAsyncIterable(new Date())).not.toBeTruthy(); + }); + + it('Should return false (not throw) for nullish and primitive values', () => { + expect(() => isAsyncIterable(null)).not.toThrow(); + expect(() => isAsyncIterable(undefined)).not.toThrow(); + expect(() => isAsyncIterable(5)).not.toThrow(); + expect(isAsyncIterable(null)).not.toBeTruthy(); + expect(isAsyncIterable(undefined)).not.toBeTruthy(); + expect(isAsyncIterable(5)).not.toBeTruthy(); + }); +}); diff --git a/test/is-constructor.spec.ts b/test/is-constructor.spec.ts index 9562fe5..4c253ce 100644 --- a/test/is-constructor.spec.ts +++ b/test/is-constructor.spec.ts @@ -15,4 +15,8 @@ describe('isConstructor', () => { expect(isConstructor('')).not.toBeTruthy(); expect(isConstructor(new Date())).not.toBeTruthy(); }); + + it('Should return a strict boolean, even for arrow functions', () => { + expect(isConstructor(() => {})).toBe(false); + }); }); diff --git a/test/is-iterable.spec.ts b/test/is-iterable.spec.ts index dbc0c7e..27fbe32 100644 --- a/test/is-iterable.spec.ts +++ b/test/is-iterable.spec.ts @@ -11,4 +11,19 @@ describe('isIterable', () => { expect(isIterable({})).not.toBeTruthy(); expect(isIterable(new Date())).not.toBeTruthy(); }); + + it('Should return true for strings', () => { + expect(isIterable('abc')).toBeTruthy(); + }); + + it('Should return false (not throw) for nullish and primitive values', () => { + expect(() => isIterable(null)).not.toThrow(); + expect(() => isIterable(undefined)).not.toThrow(); + expect(() => isIterable(5)).not.toThrow(); + expect(() => isIterable(true)).not.toThrow(); + expect(isIterable(null)).not.toBeTruthy(); + expect(isIterable(undefined)).not.toBeTruthy(); + expect(isIterable(5)).not.toBeTruthy(); + expect(isIterable(true)).not.toBeTruthy(); + }); }); diff --git a/test/is-object.spec.ts b/test/is-object.spec.ts new file mode 100644 index 0000000..c38c25e --- /dev/null +++ b/test/is-object.spec.ts @@ -0,0 +1,34 @@ +import { isObject, isPlainObject } from '@jsopen/objects'; +import { expect } from 'expect'; + +describe('isObject', () => { + it('should return true for objects', () => { + expect(isObject({})).toBeTruthy(); + expect(isObject(new Date())).toBeTruthy(); + expect(isObject(new (class {})())).toBeTruthy(); + }); + + it('should return false for arrays, null and primitives', () => { + expect(isObject([])).toBe(false); + expect(isObject(null)).toBe(false); + expect(isObject(undefined)).toBe(false); + expect(isObject(5)).toBe(false); + expect(isObject('x')).toBe(false); + }); +}); + +describe('isPlainObject', () => { + it('should return true for plain objects', () => { + expect(isPlainObject({})).toBeTruthy(); + expect(isPlainObject(new Object())).toBeTruthy(); + expect(isPlainObject(Object.create(null))).toBeTruthy(); + }); + + it('should return false for class instances, arrays and primitives', () => { + expect(isPlainObject(new (class {})())).toBe(false); + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject(5)).toBe(false); + }); +}); diff --git a/test/merge.spec.ts b/test/merge.spec.ts index 1ef24b0..f0da50b 100644 --- a/test/merge.spec.ts +++ b/test/merge.spec.ts @@ -9,6 +9,16 @@ describe('merge', () => { expect(() => merge({}, 'string')).toThrow('must be an object'); }); + it('should mention "source" (not "target") when source is invalid', () => { + // @ts-ignore + expect(() => merge({}, 'string')).toThrow('"source" argument'); + }); + + it('should accept an array as a valid target', () => { + const o = merge([1, 2], { 2: 3 } as any); + expect(o).toStrictEqual([1, 2, 3]); + }); + it('should ignore source if null', () => { const a: any = {}; const b: any = merge(a, undefined as any); @@ -370,6 +380,26 @@ describe('merge', () => { expect(o.foo).toEqual([1, 2, 3, { a: 1 }]); }); + it('should copy an array element of a multi-source list onto an array target', () => { + // `[1, 2]` and `[3, 4]` are two sequential sources here (multi-source + // semantics), each of which is itself an array value to copy in full. + const target: any = []; + const o: any = merge(target, [ + [1, 2], + [3, 4], + ]); + expect(o).toBe(target); + expect(o).toStrictEqual([3, 4]); + }); + + it('should deep clone an array-valued source within a multi-source list', () => { + const target: any = []; + const inner = [{ a: 1 }]; + const o: any = merge(target, [inner], { deep: true }); + expect(o).toStrictEqual(inner); + expect(o[0]).not.toBe(inner[0]); + }); + it('should apply filter on target object', () => { const a: any = { a: 1, b: 2 }; const b: any = { a: 2, c: false }; diff --git a/test/omit-null.spec.ts b/test/omit-null.spec.ts index 47975b1..ceea8ed 100644 --- a/test/omit-null.spec.ts +++ b/test/omit-null.spec.ts @@ -36,4 +36,11 @@ describe('omitNull', () => { b: [{ a: 1 }], }); }); + + it('should omit null fields from objects within a top-level array', () => { + const a: any = [{ a: 1, b: null }, { c: 2 }]; + const x = omitNull(a, true); + expect(Array.isArray(x)).toBeTruthy(); + expect(x).toStrictEqual([{ a: 1 }, { c: 2 }]); + }); }); diff --git a/test/omit-nullish.spec.ts b/test/omit-nullish.spec.ts index f945753..8e62258 100644 --- a/test/omit-nullish.spec.ts +++ b/test/omit-nullish.spec.ts @@ -38,4 +38,11 @@ describe('omitNullish', () => { b: [{ a: 1 }], }); }); + + it('should omit nullish fields from objects within a top-level array', () => { + const a: any = [{ a: 1, b: null, c: undefined }, { d: 2 }]; + const x = omitNullish(a, true); + expect(Array.isArray(x)).toBeTruthy(); + expect(x).toStrictEqual([{ a: 1 }, { d: 2 }]); + }); }); diff --git a/test/omit-undefined.spec.ts b/test/omit-undefined.spec.ts index b443d49..48cb4e2 100644 --- a/test/omit-undefined.spec.ts +++ b/test/omit-undefined.spec.ts @@ -36,4 +36,11 @@ describe('omitUndefined', () => { b: [{ a: 1 }], }); }); + + it('should omit undefined fields from objects within a top-level array', () => { + const a: any = [{ a: 1, b: undefined }, { c: 2 }]; + const x = omitUndefined(a, true); + expect(Array.isArray(x)).toBeTruthy(); + expect(x).toStrictEqual([{ a: 1 }, { c: 2 }]); + }); }); diff --git a/test/omit.spec.ts b/test/omit.spec.ts index 691ed55..d3eddbb 100644 --- a/test/omit.spec.ts +++ b/test/omit.spec.ts @@ -14,4 +14,12 @@ describe('omit', () => { c: 3, }); }); + + it('should return a new array when given a top-level array', () => { + const a = [1, 2, 3]; + const x: any = omit(a, []); + expect(Array.isArray(x)).toBeTruthy(); + expect(x).toStrictEqual(a); + expect(x).not.toBe(a); + }); });