@helpers4/object 3.0.0-alpha.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -4,6 +4,44 @@ var UNSAFE_KEYS = /* @__PURE__ */ new Set([
4
4
  "prototype"
5
5
  ]);
6
6
  //#endregion
7
+ //#region helpers/object/clone.ts
8
+ /**
9
+ * This file is part of helpers4.
10
+ * Copyright (C) 2025 baxyz
11
+ * SPDX-License-Identifier: LGPL-3.0-or-later
12
+ */
13
+ /**
14
+ * Creates a shallow copy of a value — one level deep, unlike {@link cloneDeep}.
15
+ *
16
+ * Unlike a plain `{ ...value }` spread, this correctly reconstructs `Date`,
17
+ * `Map`, `Set`, and arrays instead of producing an empty (or wrong-shaped)
18
+ * plain object for them. Primitives are returned as-is. Any other object
19
+ * (including class instances not listed above) has its own enumerable string
20
+ * keys shallow-copied into a plain object — the same fallback `cloneDeep`
21
+ * uses, so the two stay consistent for types neither one special-cases.
22
+ *
23
+ * @param value - The value to shallow-clone
24
+ * @returns A shallow copy of `value`
25
+ * @example
26
+ * clone({ a: { b: 1 } })
27
+ * // => { a: { b: 1 } } — new object, but `a` is the *same* nested reference
28
+ * @example
29
+ * clone(new Date('2024-01-01')) // => new Date with the same timestamp
30
+ * clone(new Map([['a', 1]])) // => new Map with the same entries
31
+ * clone([1, 2, 3]) // => new array, same elements
32
+ * @since 3.0.0
33
+ */
34
+ function clone(value) {
35
+ if (value === null || typeof value !== "object") return value;
36
+ if (value instanceof Date) return new Date(value.getTime());
37
+ if (value instanceof Map) return new Map(value);
38
+ if (value instanceof Set) return new Set(value);
39
+ if (Array.isArray(value)) return [...value];
40
+ const result = {};
41
+ for (const key of Object.keys(value)) if (!UNSAFE_KEYS.has(key)) result[key] = value[key];
42
+ return result;
43
+ }
44
+ //#endregion
7
45
  //#region helpers/object/cloneDeep.ts
8
46
  /**
9
47
  * This file is part of helpers4.
@@ -166,10 +204,14 @@ function normalizeTimestamp(timestamp) {
166
204
  * SPDX-License-Identifier: LGPL-3.0-or-later
167
205
  */
168
206
  /**
169
- * Type guard that checks whether a value satisfies the {@link EpochMilliseconds}
170
- * structural interface (e.g. `Temporal.Instant`, `Temporal.ZonedDateTime`).
207
+ * Type guard that checks whether a value is a `Temporal.Instant` or
208
+ * `Temporal.ZonedDateTime`.
209
+ *
210
+ * Duck-types the `epochMilliseconds` property rather than using `instanceof`,
211
+ * so values crossing a realm boundary (vm context, worker, iframe) are still
212
+ * recognized correctly.
171
213
  */
172
- function hasEpochMilliseconds(value) {
214
+ function isEpochMillisecondsLike(value) {
173
215
  return typeof value === "object" && value !== null && "epochMilliseconds" in value && typeof value.epochMilliseconds === "number";
174
216
  }
175
217
  /**
@@ -200,7 +242,7 @@ function hasEpochMilliseconds(value) {
200
242
  function ensureDate(input) {
201
243
  if (input === null || input === void 0 || input === "" || input === 0) return null;
202
244
  if (input instanceof Date) return isNaN(input.getTime()) ? null : input;
203
- if (hasEpochMilliseconds(input)) {
245
+ if (isEpochMillisecondsLike(input)) {
204
246
  const date = new Date(input.epochMilliseconds);
205
247
  return isNaN(date.getTime()) ? null : date;
206
248
  }
@@ -445,43 +487,92 @@ function equalsShallow(objA, objB) {
445
487
  return true;
446
488
  }
447
489
  //#endregion
448
- //#region helpers/object/_parsePath.ts
490
+ //#region helpers/object/flatten.ts
449
491
  /**
450
492
  * This file is part of helpers4.
451
493
  * Copyright (C) 2025 baxyz
452
494
  * SPDX-License-Identifier: LGPL-3.0-or-later
453
495
  */
496
+ function flattenInto(obj, prefix, result) {
497
+ for (const [key, value] of Object.entries(obj)) {
498
+ const path = prefix ? `${prefix}.${key}` : key;
499
+ if (isPlainObject(value) && Object.keys(value).length > 0) flattenInto(value, path, result);
500
+ else result[path] = value;
501
+ }
502
+ }
454
503
  /**
455
- * Parses a string path into an array of string/number segments.
504
+ * Flattens a nested object into a single-level object whose keys are the
505
+ * dot-notation path to each leaf value. The inverse of {@link unflatten}.
506
+ *
507
+ * Only **plain objects** are recursed into — arrays, `Date`, `Map`, `RegExp`,
508
+ * class instances, and empty plain objects `{}` are kept as opaque leaf
509
+ * values. This keeps `flatten`/`unflatten` a clean, invertible pair: arrays
510
+ * can't be losslessly told apart from plain objects once reduced to dotted
511
+ * keys, so this implementation doesn't attempt it.
512
+ *
513
+ * Caveat shared by every dotted-path flattening scheme: a key that itself
514
+ * contains a literal `.` is indistinguishable from real nesting once
515
+ * flattened (`{ 'a.b': 1 }` and `{ a: { b: 1 } }` both produce `{ 'a.b': 1 }`).
516
+ *
517
+ * @param obj - The object to flatten
518
+ * @returns A single-level object with dot-notation keys
519
+ * @example
520
+ * flatten({ a: { b: { c: 1 }, d: 2 } })
521
+ * // => { 'a.b.c': 1, 'a.d': 2 }
522
+ * @example
523
+ * flatten({ a: [1, 2], b: new Date(0) })
524
+ * // => { a: [1, 2], b: Date } (arrays and special objects stay as leaves)
525
+ * @since 3.0.0
526
+ */
527
+ function flatten(obj) {
528
+ const result = {};
529
+ flattenInto(obj, "", result);
530
+ return result;
531
+ }
532
+ //#endregion
533
+ //#region helpers/object/parsePropertyPath.ts
534
+ /**
535
+ * This file is part of helpers4.
536
+ * Copyright (C) 2025 baxyz
537
+ * SPDX-License-Identifier: LGPL-3.0-or-later
538
+ */
539
+ var BRACKET = /\[(\d+)\]/g;
540
+ var MAX_CACHE_SIZE = 500;
541
+ var pathCache = /* @__PURE__ */ new Map();
542
+ /**
543
+ * Parses a dot/bracket-notation property path into an array of string/number
544
+ * key segments — the same notation accepted by {@link get} and {@link set}.
456
545
  *
457
546
  * - Dot separators (`.`) split segments; each segment becomes a string key.
458
547
  * - Bracket indices (`[n]`) become number keys.
459
548
  * - A leading `.` is treated as "current level" and stripped before parsing,
460
549
  * so `.[0]` ≡ `[0]` and `.a.b` ≡ `a.b`.
461
- * - Empty string (or bare `.`) returns `['']` (addresses the `''` key on the root object).
550
+ * - Empty string (or a bare `.`) returns `['']` (addresses the `''` key on the root object).
462
551
  * - Consecutive dots (`a..b`) produce an empty-string segment: `['a', '', 'b']`.
463
- * - Non-bracket text trailing a `]` within the same dot-segment throws a RangeError;
464
- * use a dot separator instead (`'a[0].b'` not `'a[0]b'`).
465
552
  *
466
- * @ignore
553
+ * Results are cached (up to 500 distinct path strings, oldest evicted first)
554
+ * since real-world callers tend to reuse a small, fixed set of literal paths.
555
+ *
556
+ * @param path - The dot/bracket-notation path string to parse
557
+ * @returns The parsed key segments
558
+ * @throws {RangeError} When non-bracket text trails the last `]` within a
559
+ * dot-segment (e.g. `'a[0]b'` is ambiguous — use `'a[0].b'` instead)
467
560
  * @example
468
- * parsePath('a.b.c') // => ['a', 'b', 'c'] (all strings)
469
- * parsePath('layers.1.x') // => ['layers', '1', 'x'] (all strings)
470
- * parsePath('layers[1].x') // => ['layers', 1, 'x'] (bracket → number)
471
- * parsePath('.[0]') // => [0] (leading dot stripped)
472
- * parsePath('a..b') // => ['a', '', 'b'] (empty segment preserved)
473
- * parsePath('.') // => [''] (leading dot → empty-string key)
561
+ * parsePropertyPath('a.b.c') // => ['a', 'b', 'c']
562
+ * parsePropertyPath('layers[1].x') // => ['layers', 1, 'x']
563
+ * parsePropertyPath('.[0]') // => [0] (leading dot stripped)
564
+ * parsePropertyPath('a..b') // => ['a', '', 'b'] (empty segment preserved)
565
+ * @since 3.0.0
474
566
  */
475
- var BRACKET = /\[(\d+)\]/g;
476
- var parsePathCache = /* @__PURE__ */ new Map();
477
- function parsePath(path) {
478
- const cached = parsePathCache.get(path);
567
+ function parsePropertyPath(path) {
568
+ const cached = pathCache.get(path);
479
569
  if (cached !== void 0) return cached;
480
- const result = Object.freeze(parsePathUncached(path));
481
- parsePathCache.set(path, result);
570
+ const result = Object.freeze(parseUncached(path));
571
+ if (pathCache.size >= MAX_CACHE_SIZE) pathCache.delete(pathCache.keys().next().value);
572
+ pathCache.set(path, result);
482
573
  return result;
483
574
  }
484
- function parsePathUncached(path) {
575
+ function parseUncached(path) {
485
576
  if (path === "") return [""];
486
577
  if (path[0] === ".") path = path.slice(1);
487
578
  if (path === "") return [""];
@@ -496,7 +587,7 @@ function parsePathUncached(path) {
496
587
  last = BRACKET.lastIndex;
497
588
  }
498
589
  if (last === 0) result.push(dotSegment);
499
- else if (last < dotSegment.length) throw new RangeError(`parsePath: non-bracket text '${dotSegment.slice(last)}' after ']' in segment '${dotSegment}' — use 'a[0].b' instead of 'a[0]b'`);
590
+ else if (last < dotSegment.length) throw new RangeError(`parsePropertyPath: non-bracket text '${dotSegment.slice(last)}' after ']' in segment '${dotSegment}' — use 'a[0].b' instead of 'a[0]b'`);
500
591
  }
501
592
  return result;
502
593
  }
@@ -509,7 +600,7 @@ function parsePathUncached(path) {
509
600
  */
510
601
  function get(obj, path, defaultValue) {
511
602
  if (obj == null) return defaultValue;
512
- const keys = typeof path === "string" ? parsePath(path) : path;
603
+ const keys = typeof path === "string" ? parsePropertyPath(path) : path;
513
604
  let result = obj;
514
605
  for (const key of keys) {
515
606
  if (result == null || typeof result !== "object") return defaultValue;
@@ -728,6 +819,37 @@ function omit(obj, keys) {
728
819
  return result;
729
820
  }
730
821
  //#endregion
822
+ //#region helpers/object/omitBy.ts
823
+ /**
824
+ * This file is part of helpers4.
825
+ * Copyright (C) 2025 baxyz
826
+ * SPDX-License-Identifier: LGPL-3.0-or-later
827
+ */
828
+ /**
829
+ * Creates a new object without the own enumerable entries for which
830
+ * `predicate` returns `true`.
831
+ *
832
+ * Complements {@link omit} for when the keys to remove aren't known ahead of
833
+ * time — `omit` takes an explicit key list, `omitBy` takes a predicate.
834
+ *
835
+ * @param obj - The source object. `null`/`undefined` pass through unchanged.
836
+ * @param predicate - Called with `(value, key)` for each own enumerable entry
837
+ * @returns A new object without the matching entries
838
+ * @example
839
+ * omitBy({ a: 1, b: undefined, c: 2 }, (value) => value === undefined)
840
+ * // => { a: 1, c: 2 }
841
+ * @since 3.0.0
842
+ */
843
+ function omitBy(obj, predicate) {
844
+ if (obj == null) return obj;
845
+ const result = {};
846
+ for (const key of Object.keys(obj)) {
847
+ const typedKey = key;
848
+ if (!predicate(obj[typedKey], typedKey)) result[key] = obj[typedKey];
849
+ }
850
+ return result;
851
+ }
852
+ //#endregion
731
853
  //#region helpers/object/pick.ts
732
854
  /**
733
855
  * This file is part of helpers4.
@@ -744,6 +866,38 @@ function pick(obj, keys) {
744
866
  return result;
745
867
  }
746
868
  //#endregion
869
+ //#region helpers/object/pickBy.ts
870
+ /**
871
+ * This file is part of helpers4.
872
+ * Copyright (C) 2025 baxyz
873
+ * SPDX-License-Identifier: LGPL-3.0-or-later
874
+ */
875
+ /**
876
+ * Creates a new object with only the own enumerable entries for which
877
+ * `predicate` returns `true`.
878
+ *
879
+ * Complements {@link pick} for when the keys to keep aren't known ahead of
880
+ * time — `pick` takes an explicit key list, `pickBy` takes a predicate.
881
+ *
882
+ * @param obj - The source object. `null`/`undefined` pass through unchanged.
883
+ * @param predicate - Called with `(value, key)` for each own enumerable entry
884
+ * @returns A new object with only the matching entries
885
+ * @example
886
+ * pickBy({ a: 1, b: 0, c: 2 }, (value) => value > 0)
887
+ * // => { a: 1, c: 2 }
888
+ * @since 3.0.0
889
+ */
890
+ function pickBy(obj, predicate) {
891
+ if (obj == null) return obj;
892
+ const result = {};
893
+ for (const key of Object.keys(obj)) {
894
+ if (UNSAFE_KEYS.has(key)) continue;
895
+ const typedKey = key;
896
+ if (predicate(obj[typedKey], typedKey)) result[key] = obj[typedKey];
897
+ }
898
+ return result;
899
+ }
900
+ //#endregion
747
901
  //#region helpers/guard/isNullish.ts
748
902
  /**
749
903
  * This file is part of helpers4.
@@ -804,7 +958,7 @@ function safeJsonParse(input, fallback) {
804
958
  * SPDX-License-Identifier: LGPL-3.0-or-later
805
959
  */
806
960
  function set(obj, path, value) {
807
- const keys = typeof path === "string" ? parsePath(path) : path;
961
+ const keys = typeof path === "string" ? parsePropertyPath(path) : path;
808
962
  if (keys.length === 0) return obj;
809
963
  if (keys.some((k) => typeof k === "string" && UNSAFE_KEYS.has(k))) return obj;
810
964
  let current = obj;
@@ -817,6 +971,85 @@ function set(obj, path, value) {
817
971
  return obj;
818
972
  }
819
973
  //#endregion
820
- export { cloneDeep, compact, diff, equalsDeep, equalsShallow, get, groupBy, invert, isEmpty, isNonEmpty, map, mergeDeep, omit, pick, removeUndefinedNull, safeJsonParse, set };
974
+ //#region helpers/object/unflatten.ts
975
+ /**
976
+ * This file is part of helpers4.
977
+ * Copyright (C) 2025 baxyz
978
+ * SPDX-License-Identifier: LGPL-3.0-or-later
979
+ */
980
+ /**
981
+ * Rebuilds a nested object from a single-level object whose keys are
982
+ * dot-notation paths. The inverse of {@link flatten}.
983
+ *
984
+ * Uses {@link set} internally, so intermediate nodes are always created as
985
+ * plain objects (never arrays — see {@link flatten}'s doc for why), and any
986
+ * key segment equal to `__proto__`, `constructor`, or `prototype` is silently
987
+ * rejected (same prototype-pollution guard as `set`).
988
+ *
989
+ * @param obj - A single-level object with dot-notation keys
990
+ * @returns The rebuilt nested object
991
+ * @example
992
+ * unflatten({ 'a.b.c': 1, 'a.d': 2 })
993
+ * // => { a: { b: { c: 1 }, d: 2 } }
994
+ * @since 3.0.0
995
+ */
996
+ function unflatten(obj) {
997
+ const result = {};
998
+ for (const [key, value] of Object.entries(obj)) set(result, key, value);
999
+ return result;
1000
+ }
1001
+ //#endregion
1002
+ //#region helpers/object/unset.ts
1003
+ /**
1004
+ * This file is part of helpers4.
1005
+ * Copyright (C) 2025 baxyz
1006
+ * SPDX-License-Identifier: LGPL-3.0-or-later
1007
+ */
1008
+ /**
1009
+ * Removes the value at a dot/bracket-notation path or explicit key array,
1010
+ * mutating the object in place. Uses the same path syntax as {@link get}/{@link set}.
1011
+ *
1012
+ * A missing intermediate segment is a no-op (nothing to remove), not an error.
1013
+ * As with `set`, any path containing a string segment equal to `__proto__`,
1014
+ * `constructor`, or `prototype` is rejected and the object is returned unchanged.
1015
+ *
1016
+ * The removed key stops appearing in `Object.keys`/`for...in` — unlike setting
1017
+ * it to `undefined`, which would keep the key present.
1018
+ *
1019
+ * @param obj - The object to mutate
1020
+ * @param path - Dot/bracket-notation string, or explicit `PropertyKey[]`
1021
+ * @returns The same object reference, with the key removed if it existed
1022
+ * @example
1023
+ * unset({ a: { b: 1, c: 2 } }, 'a.b')
1024
+ * // => { a: { c: 2 } }
1025
+ * @example
1026
+ * unset({ a: 1 }, 'x.y') // path doesn't exist — no-op
1027
+ * // => { a: 1 }
1028
+ * @since 3.0.0
1029
+ */
1030
+ function unset(obj, path) {
1031
+ const keys = typeof path === "string" ? parsePropertyPath(path) : path;
1032
+ if (keys.length === 0) return obj;
1033
+ if (keys.some((key) => typeof key === "string" && UNSAFE_KEYS.has(key))) return obj;
1034
+ let current = obj;
1035
+ for (let i = 0; i < keys.length - 1; i++) {
1036
+ if (current == null || typeof current !== "object") return obj;
1037
+ current = current[keys[i]];
1038
+ }
1039
+ if (current != null && typeof current === "object") delete current[keys[keys.length - 1]];
1040
+ return obj;
1041
+ }
1042
+ //#endregion
1043
+ //#region helpers/object/update.ts
1044
+ /**
1045
+ * This file is part of helpers4.
1046
+ * Copyright (C) 2025 baxyz
1047
+ * SPDX-License-Identifier: LGPL-3.0-or-later
1048
+ */
1049
+ function update(obj, path, updater) {
1050
+ return set(obj, path, updater(get(obj, path)));
1051
+ }
1052
+ //#endregion
1053
+ export { clone, cloneDeep, compact, diff, equalsDeep, equalsShallow, flatten, get, groupBy, invert, isEmpty, isNonEmpty, map, mergeDeep, omit, omitBy, parsePropertyPath, pick, pickBy, removeUndefinedNull, safeJsonParse, set, unflatten, unset, update };
821
1054
 
822
1055
  //# sourceMappingURL=index.js.map