Skip to content

Commit 5e887f4

Browse files
nev21Copilot
andauthored
Add new object utility helpers and harden defaults against prototype pollution (#564)
- add new object helpers: objPick, objOmit, objPickBy, objOmitBy, objMapValues, objMergeIf, objDefaults, and objDiff - export the new helpers from the public index - add common test coverage for pick/omit, mapValues, defaults/mergeIf, and diff behavior - fix objPick/objOmit overload compatibility by widening implementation signatures to PropertyKey - preserve numeric key omission at runtime in objOmit when typed overloads pass numeric keys - document objDefaults as similar to Lodash defaults while clarifying it only uses own enumerable source properties - harden objMergeIf and objDefaults against prototype pollution by skipping unsafe keys and blocking unsafe built-in prototype targets --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 0a486d3 commit 5e887f4

26 files changed

Lines changed: 1969 additions & 108 deletions

.size-limit.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,42 @@
22
{
33
"name": "es5-full",
44
"path": "lib/dist/es5/mod/ts-utils.js",
5-
"limit": "31 kb",
5+
"limit": "32.5 kb",
66
"brotli": false,
77
"running": false
88
},
99
{
1010
"name": "es6-full",
1111
"path": "lib/dist/es6/mod/ts-utils.js",
12-
"limit": "30 kb",
12+
"limit": "31.5 kb",
1313
"brotli": false,
1414
"running": false
1515
},
1616
{
1717
"name": "es5-full-brotli",
1818
"path": "lib/dist/es5/mod/ts-utils.js",
19-
"limit": "11 kb",
19+
"limit": "11.5 kb",
2020
"brotli": true,
2121
"running": false
2222
},
2323
{
2424
"name": "es6-full-brotli",
2525
"path": "lib/dist/es6/mod/ts-utils.js",
26-
"limit": "11 kb",
26+
"limit": "11.5 kb",
2727
"brotli": true,
2828
"running": false
2929
},
3030
{
3131
"name": "es5-zip",
3232
"path": "lib/dist/es5/mod/ts-utils.js",
33-
"limit": "12 Kb",
33+
"limit": "12.5 Kb",
3434
"gzip": true,
3535
"running": false
3636
},
3737
{
3838
"name": "es6-zip",
3939
"path": "lib/dist/es6/mod/ts-utils.js",
40-
"limit": "12 Kb",
40+
"limit": "12.5 Kb",
4141
"gzip": true,
4242
"running": false
4343
},

README.md

Lines changed: 3 additions & 1 deletion
Large diffs are not rendered by default.

docs/feature-backlog.md

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,29 +36,15 @@ Notes:
3636
- Iterator helpers are intentionally listed as utility suggestions here rather than standard-language mappings.
3737
- Implementations should include ES5 polyfills where applicable for v0.x/v1.x compatibility
3838

39-
### A. Object Utilities (Medium Value)
40-
41-
- `objPick` / `objOmit`
42-
- `objMapValues`
43-
- `objMergeIf`
44-
- `objDiff`
45-
- `objPickBy` / `objOmitBy`
46-
- `objDefaults` for shallow default assignment without overriding defined values
47-
48-
Notes:
49-
50-
- maintain plain-object safety patterns
51-
- avoid behavior changes to existing deep copy helpers
52-
53-
### B. Iterator and Collection Helpers (Medium Value)
39+
### A. Iterator and Collection Helpers (Medium Value)
5440

5541
- `iterMap`, `iterFilter`, `iterTake` – Iterator transformation helpers
5642
- `iterReduce`, `iterSome`, `iterEvery` – Iterator reduction/testing
5743
- `iterToArray` for predictable materialization of iterables / iterators
5844
- `arrToMap` helpers with stable key selection
5945
- lightweight set operations for iterables
6046

61-
### C. Reliability and Tooling (High Value)
47+
### B. Reliability and Tooling (High Value)
6248

6349
- keep bundle-size thresholds justified with measured report
6450
- require test parity for polyfill vs native behavior

lib/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ export {
111111
ObjDefinePropDescriptor, ObjDefinePropDescriptorMap, objDefine, objDefineProp, objDefineGet, objDefineAccessors,
112112
objDefineProperties, objDefineProps
113113
} from "./object/define";
114-
export { objForEachKey } from "./object/for_each_key";
115-
export { forEachOwnKeySafe } from "./object/forEachOwnKeySafe";
114+
export { objForEachKey, objForEachKeySafe } from "./object/for_each_key";
115+
export { forEachOwnKey, forEachOwnKeySafe } from "./object/forEachOwnKey";
116116
export { isUnsafePropKey } from "./object/isUnsafePropKey";
117117
export {
118118
objGetOwnPropertyDescriptor, objGetOwnPropertyDescriptors, objGetOwnPropertyNames,
@@ -131,6 +131,10 @@ export { objPreventExtensions, objIsExtensible } from "./object/prevent_extensio
131131
export { objPropertyIsEnumerable } from "./object/property_is_enumerable";
132132
export { objSetPrototypeOf } from "./object/set_proto";
133133
export { objIsFrozen, objIsSealed } from "./object/object_state";
134+
export { objPick, objOmit, objPickBy, objOmitBy } from "./object/pick";
135+
export { objMapValues } from "./object/map_values";
136+
export { objMergeIf, objDefaults } from "./object/defaults";
137+
export { objDiff } from "./object/diff";
134138
export { strCamelCase, strCapitalizeWords, strKebabCase, strLetterCase, strSnakeCase } from "./string/conversion";
135139
export { strCount } from "./string/count";
136140
export { strAt } from "./string/at";

lib/src/internal/unwrapFunction.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,33 @@ export function _unwrapFunctionWithPoly<T, P extends (...args: any) => any>(func
5959
};
6060
}
6161

62+
/**
63+
* @internal
64+
* @ignore
65+
* Internal helper to convert an expanded function back into an instance `this` function call
66+
* using only the provided class / prototype function or the polyfill. Unlike
67+
* {@link _unwrapFunctionWithPoly}, this helper does not perform any instance-level lookup.
68+
* @param funcName - The function name to call on the first argument passed to the wrapped function
69+
* @param clsProto - The Class or class prototype to use if the function exists.
70+
* @param polyFunc - The function to call if the class / prototype function is not available
71+
* @returns A function which will call the resolved function against the first passed argument and pass on the remaining arguments
72+
*/
73+
/*#__NO_SIDE_EFFECTS__*/
74+
75+
export function _unwrapFunctionNoInstWithPoly<T, P extends (...args: any) => any>(funcName: keyof T, clsProto?: T, polyFunc?: P) {
76+
let clsFn = clsProto ? clsProto[funcName] : NULL_VALUE;
77+
78+
return function(thisArg: any): ReturnType<P> {
79+
let theFunc = clsFn;
80+
if (theFunc || polyFunc) {
81+
let theArgs = arguments;
82+
return ((theFunc || polyFunc) as Function).apply(thisArg, theFunc ? ArrSlice[CALL](theArgs, 1) : theArgs);
83+
}
84+
85+
throwTypeError("\"" + asString(funcName) + "\" not defined for " + dumpObj(thisArg));
86+
};
87+
}
88+
6289
/**
6390
* @internal
6491
* @ignore

lib/src/object/copy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { arrForEach } from "../array/forEach";
1010
import { isArray, isDate, isNullOrUndefined, isPrimitiveType } from "../helpers/base";
1111
import { CALL, FUNCTION, NULL_VALUE, OBJECT } from "../internal/constants";
1212
import { objDefine } from "./define";
13-
import { forEachOwnKeySafe } from "./forEachOwnKeySafe";
13+
import { forEachOwnKeySafe } from "./forEachOwnKey";
1414
import { isPlainObject } from "./is_plain_object";
1515
import { isUnsafeTarget } from "./isUnsafeTarget";
1616

lib/src/object/defaults.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* @nevware21/ts-utils
3+
* https://github.com/nevware21/ts-utils
4+
*
5+
* Copyright (c) 2026 NevWare21 Solutions LLC
6+
* Licensed under the MIT license.
7+
*/
8+
9+
import { arrForEach } from "../array/forEach";
10+
import { isStrictUndefined } from "../helpers/base";
11+
import { forEachOwnKeySafe } from "./forEachOwnKey";
12+
import { isUnsafeTarget } from "./isUnsafeTarget";
13+
14+
/**
15+
* Copies own enumerable properties from `source` to `target` **only** when the predicate
16+
* returns truthy for that key/value pair. All other own properties on `target` are left
17+
* unchanged.
18+
*
19+
* Security behavior: this helper iterates source keys via {@link forEachOwnKeySafe}, so unsafe
20+
* keys (`__proto__`, `constructor`, `prototype`) are ignored even if the predicate returns true.
21+
* It also skips all writes when `target` is a guarded built-in prototype object per
22+
* {@link isUnsafeTarget}.
23+
* @since 0.14.0
24+
* @group Object
25+
* @typeParam T - The type of the target object
26+
* @param target - The target object to merge into
27+
* @param source - The source object to merge from. Null or undefined source is safely ignored.
28+
* @param predicate - A function `(key, srcValue, tgtValue) => boolean` called for each own
29+
* enumerable safe property key of `source` (string and symbol). The property is merged only
30+
* when the predicate returns truthy.
31+
* @returns The `target` object (mutated in place).
32+
* @example
33+
* ```ts
34+
* const target = { a: 1, b: 2 };
35+
* const source = { b: 99, c: 3 };
36+
*
37+
* // Only merge keys whose source value is greater than 2
38+
* objMergeIf(target, source, (key, srcVal) => srcVal > 2);
39+
* // target => { a: 1, b: 99, c: 3 }
40+
*
41+
* // Only merge when the key does not already exist in target
42+
* const t2 = { x: 10 };
43+
* objMergeIf(t2, { x: 99, y: 5 }, (key, _sv, tgtVal) => tgtVal === undefined);
44+
* // t2 => { x: 10, y: 5 }
45+
*
46+
* // Unsafe keys are filtered even if predicate returns true
47+
* const t3: any = {};
48+
* objMergeIf(t3, { constructor: "ignored", safe: 1 } as any, () => true);
49+
* // t3 => { safe: 1 }
50+
* ```
51+
*/
52+
export function objMergeIf<T>(
53+
target: T,
54+
source: Record<PropertyKey, any> | null | undefined,
55+
predicate: (key: PropertyKey, srcValue: any, tgtValue: any) => boolean
56+
): T {
57+
if (target && source && !isUnsafeTarget(target)) {
58+
forEachOwnKeySafe(source, (key, value) => {
59+
if (predicate(key, value, (target as any)[key])) {
60+
(target as any)[key] = value;
61+
}
62+
});
63+
}
64+
return target;
65+
}
66+
67+
/**
68+
* Assigns own enumerable properties from one or more `sources` onto `target` **only** for
69+
* properties that are currently `undefined` on `target` — it never overwrites an already-defined
70+
* value (including `null`). Sources are processed left-to-right; the first defined value wins.
71+
*
72+
* This is similar to Lodash `_.defaults()`, but it only considers each source object's own
73+
* enumerable properties and does not copy inherited source properties.
74+
*
75+
* **Security filtering:** to guard against prototype-pollution attacks this function applies two
76+
* layers of protection:
77+
* - **Unsafe source keys** (`__proto__`, `constructor`, `prototype`) are silently skipped and
78+
* never written to `target`, even when those keys exist as own enumerable properties of a source.
79+
* - **Guarded targets** (built-in prototype objects such as `Object.prototype`,
80+
* `Array.prototype`, etc.) are rejected entirely — `target` is returned unchanged.
81+
*
82+
* This means that calls like `objDefaults(obj, { constructor: fn })` or
83+
* `objDefaults(Object.prototype, ...)` are silently no-ops for the filtered keys / guarded target.
84+
* @since 0.14.0
85+
* @group Object
86+
* @typeParam T - The type of the target object
87+
* @param target - The destination object. Modified in place.
88+
* @param sources - One or more source objects. Null / undefined sources are skipped.
89+
* @returns The `target` object with all defaults applied.
90+
* @example
91+
* ```ts
92+
* const options = { timeout: 5000 };
93+
* const defaults = { timeout: 3000, retries: 3, verbose: false };
94+
*
95+
* objDefaults(options, defaults);
96+
* // => { timeout: 5000, retries: 3, verbose: false }
97+
* // `timeout` was kept because it was already defined.
98+
*
99+
* // Multiple sources — first defined value wins
100+
* objDefaults({}, { a: 1 }, { a: 99, b: 2 });
101+
* // => { a: 1, b: 2 }
102+
*
103+
* // Unsafe source keys are silently skipped (prototype-pollution guard)
104+
* const cfg: any = { host: "localhost" };
105+
* const src: any = { host: "evil.com", __proto__: { admin: true }, constructor: String };
106+
* objDefaults(cfg, src);
107+
* // => { host: "localhost" } — __proto__ and constructor were never written
108+
* ```
109+
*/
110+
export function objDefaults<T>(target: T, ...sources: Array<Partial<T> | null | undefined>): T {
111+
if (target && !isUnsafeTarget(target)) {
112+
arrForEach(sources, (source) => {
113+
if (source) {
114+
forEachOwnKeySafe(source, (key, value) => {
115+
if (isStrictUndefined((target as any)[key])) {
116+
(target as any)[key] = value;
117+
}
118+
});
119+
}
120+
});
121+
}
122+
return target;
123+
}

lib/src/object/define.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { objForEachKey } from "./for_each_key";
1212
import { ILazyValue } from "../helpers/lazy";
1313
import { _pureAssign, _pureRef } from "../internal/treeshake_helpers";
1414
import { arrForEach } from "../array/forEach";
15-
import { objPropertyIsEnumerable } from "./property_is_enumerable";
15+
import { _objPropertyIsEnumerable } from "./property_is_enumerable";
1616
import { _returnEmptyArray, _returnNothing } from "../internal/stubs";
1717

1818
const _objGetOwnPropertyDescriptor: (target: any, prop: PropertyKey) => PropertyDescriptor | undefined = (/*#__PURE__*/_pureAssign((/*#__PURE__*/_pureRef<typeof Object.getOwnPropertyDescriptor>(ObjClass as any, GET_OWN_PROPERTY_DESCRIPTOR)), _returnNothing));
@@ -268,7 +268,7 @@ export function objDefineProps<T>(target: T, propDescMap: ObjDefinePropDescripto
268268
});
269269

270270
arrForEach(_objGetOwnPropertySymbols(propDescMap), (sym) => {
271-
if (objPropertyIsEnumerable(propDescMap, sym)) {
271+
if (_objPropertyIsEnumerable(propDescMap, sym)) {
272272
props[sym] = _createProp(propDescMap[sym]);
273273
}
274274
});

lib/src/object/diff.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* @nevware21/ts-utils
3+
* https://github.com/nevware21/ts-utils
4+
*
5+
* Copyright (c) 2026 NevWare21 Solutions LLC
6+
* Licensed under the MIT license.
7+
*/
8+
9+
import { isStrictNullOrUndefined } from "../helpers/base";
10+
import { objCreate } from "./create";
11+
import { forEachOwnKey } from "./forEachOwnKey";
12+
import { objHasOwn } from "./has_own";
13+
14+
/**
15+
* Returns a shallow diff of two objects: a new object containing only the own enumerable
16+
* properties from `modified` whose values differ from the corresponding values in `base`
17+
* (using strict equality `!==`). Properties present in `modified` but not in `base` are
18+
* also included. Properties removed in `modified` (i.e. present only in `base`) are
19+
* **not** included — use the result to describe *what changed* in `modified`.
20+
* @since 0.14.0
21+
* @group Object
22+
* @typeParam T - The type of the base object
23+
* @typeParam U - The type of the modified object (defaults to `Partial<T>`)
24+
* @param base - The original / reference object
25+
* @param modified - The updated object to compare against `base`
26+
* @returns A new plain object with the keys from `modified` that differ from `base`.
27+
* Returns an empty object when the two objects are equivalent or when either argument
28+
* is null or undefined.
29+
* @example
30+
* ```ts
31+
* const prev = { x: 1, y: 2, z: 3 };
32+
* const next = { x: 1, y: 99, z: 3 };
33+
*
34+
* objDiff(prev, next); // { y: 99 }
35+
*
36+
* // Added keys are included
37+
* objDiff({ a: 1 }, { a: 1, b: 2 }); // { b: 2 }
38+
*
39+
* // Removed keys are NOT included
40+
* objDiff({ a: 1, b: 2 }, { a: 1 }); // {}
41+
*
42+
* // null / undefined values are compared strictly
43+
* objDiff({ a: null }, { a: undefined }); // { a: undefined }
44+
* ```
45+
*/
46+
/*#__NO_SIDE_EFFECTS__*/
47+
export function objDiff<T, U extends Partial<T> = Partial<T>>(base: T, modified: U): Partial<U> {
48+
const result: Partial<U> = objCreate(null);
49+
50+
if (!isStrictNullOrUndefined(base)) {
51+
forEachOwnKey(modified, (key, value) => {
52+
const hasBase = objHasOwn(base, key);
53+
const baseVal = hasBase ? (base as any)[key] : undefined;
54+
if (!hasBase || baseVal !== value) {
55+
(result as any)[key] = value;
56+
}
57+
});
58+
}
59+
60+
return result;
61+
}

0 commit comments

Comments
 (0)