diff --git a/.size-limit.json b/.size-limit.json index f384c65..23b9a5d 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,14 +2,14 @@ { "name": "es5-full", "path": "lib/dist/es5/mod/ts-utils.js", - "limit": "28.5 kb", + "limit": "29 kb", "brotli": false, "running": false }, { "name": "es6-full", "path": "lib/dist/es6/mod/ts-utils.js", - "limit": "27.5 kb", + "limit": "28 kb", "brotli": false, "running": false }, @@ -68,7 +68,7 @@ { "name": "es5-poly", "path": "lib/bundle/es5/ts-polyfills-utils.js", - "limit": "11.5 kb", + "limit": "12 kb", "brotli": false, "running": false }, diff --git a/docs/feature-backlog.md b/docs/feature-backlog.md index 0c95035..c915511 100644 --- a/docs/feature-backlog.md +++ b/docs/feature-backlog.md @@ -26,7 +26,7 @@ Identify practical, minification-friendly, cross-environment additions that fit (All major String methods currently implemented) #### Array Methods (ES6+) -- `arrFlatMap` – ES2019 (Array.prototype.flatMap) +(All major Array methods currently implemented) #### Object Utilities (ES6+) (All major Object utilities currently implemented) diff --git a/lib/src/array/callbacks.ts b/lib/src/array/callbacks.ts index a34c9e0..983260a 100644 --- a/lib/src/array/callbacks.ts +++ b/lib/src/array/callbacks.ts @@ -50,6 +50,21 @@ export type ArrPredicateCallbackFn2 = (value: T, index: number, array: T[]) = */ export type ArrMapCallbackFn = (value: T, index?: number, array?: T[]) => R; +/** + * Callback signature for {@link arrFlatMap}. Each invocation may return either a single value or + * an array of values to be flattened into the result by one level. + * + * @since 0.14.0 + * @group Array + * @group ArrayLike + * @typeParam T - Identifies the type of the array elements + * @typeParam R - Identifies the type of the flattened output values, defaults to T. + * @param value - The current element being processed in the array. + * @param index - The index of the current element being processed in the array. + * @param array - The array-like object that the `flatMap` function was called on. + */ +export type ArrFlatMapCallbackFn = (value: T, index?: number, array?: ArrayLike) => R | ReadonlyArray; + /** * Callback signature for {@link arrFrom} mapFn that is called for every element of array. Each time mapFn * executes, the returned value is added to newArray. diff --git a/lib/src/array/flatMap.ts b/lib/src/array/flatMap.ts new file mode 100644 index 0000000..3c2d6fc --- /dev/null +++ b/lib/src/array/flatMap.ts @@ -0,0 +1,105 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { ArrProto } from "../internal/constants"; +import { _unwrapFunctionWithPoly } from "../internal/unwrapFunction"; +import { _throwIfNullOrUndefined } from "../internal/throwIf"; +import { isArray, isFunction } from "../helpers/base"; +import { throwTypeError } from "../helpers/throw"; +import { ArrFlatMapCallbackFn } from "./callbacks"; +import { arrForEach } from "./forEach"; +import { fnCall } from "../funcs/funcs"; + + +/** + * The arrFlatMap() method returns a new array formed by applying a mapping function to each element + * and then flattening the mapped result by one level. + * + * This is equivalent to calling `arrMap()` followed by `arrFlatten()` with a depth of 1, but it avoids + * allocating the intermediate mapped array. + * + * Use this helper when each input item may produce zero, one, or multiple output items as part of the + * mapping step. The mapped value is flattened by exactly one level: if the callback returns an array, + * its elements are appended to the output; non-array values are appended directly. + * + * To flatten already-nested data without mapping, use `arrFlatten()` instead. + * + * `callbackFn` is invoked only for indexes of the array which have assigned values. Empty slots in + * sparse arrays are skipped. + * + * @function + * @since 0.14.0 + * @group Array + * @group ArrayLike + * @typeParam T - Identifies the type of array elements + * @typeParam R - Identifies the type of the flattened result values, defaults to T. + * @param theArray - The array or array-like object to map and flatten. + * @param callbackFn - A function that is called for each existing element and may return either a + * single value or an array of values to flatten into the result. + * @param thisArg - The value to use as the `this` when executing the `callbackFn`. + * @returns A new array containing the mapped and flattened values. + * @example + * ```ts + * arrFlatMap([1, 2, 3], (value) => [value, value * 10]); + * // [1, 10, 2, 20, 3, 30] + * + * arrFlatMap(["one", "two"], (value) => value.split("")); + * // ["o", "n", "e", "t", "w", "o"] + * + * arrFlatMap({ length: 2, 0: "a", 1: "b" }, (value, index) => [index, value]); + * // [0, "a", 1, "b"] + * + * arrFlatMap([1, 2, 3, 4], (value) => value % 2 ? [value] : []); + * // [1, 3] + * + * arrFlatMap([1, 2], (value) => [[value, value + 10]] as any); + * // [[1, 11], [2, 12]] + * ``` + */ +export const arrFlatMap = (/*#__PURE__*/_unwrapFunctionWithPoly("flatMap", ArrProto as any, polyArrFlatMap) as (theArray: ArrayLike, callbackFn: ArrFlatMapCallbackFn, thisArg?: any) => R[]); + +/** + * Polyfill implementation of Array.flatMap() for environments that don't support it. + * @since 0.14.0 + * @group Array + * @group ArrayLike + * @group Polyfill + * @typeParam T - Identifies the base type of array elements + * @typeParam R - Identifies the flattened output element type + * @param theArray - The array or array-like object to map and flatten. + * @param callbackFn - A function that is called for each existing element and may return either a + * single value or an array of values to flatten into the result. + * @param thisArg - A value to use as this when executing callbackFn. Defaults to undefined if not provided. + * @returns A new array containing the mapped and flattened values. + * @throws TypeError if theArray is null or undefined, or if callbackFn is not a function. + */ +/*#__NO_SIDE_EFFECTS__*/ +export function polyArrFlatMap(theArray: ArrayLike, callbackFn: ArrFlatMapCallbackFn, thisArg?: any): R[] { + _throwIfNullOrUndefined(theArray); + + if (!isFunction(callbackFn)) { + throwTypeError("callbackFn must be a function"); + } + + let result: R[] = []; + let callbackThis = arguments.length > 2 ? thisArg : undefined; + + arrForEach(theArray, (theValue, index) => { + let value = fnCall(callbackFn, callbackThis, theValue, index, theArray as any); + if (isArray(value)) { + arrForEach(value as any, (mappedValue) => { + result.push(mappedValue); + }); + } else { + result.push(value as R); + } + }); + + + return result; +} \ No newline at end of file diff --git a/lib/src/array/flatten.ts b/lib/src/array/flatten.ts index 1f513cc..b794b94 100644 --- a/lib/src/array/flatten.ts +++ b/lib/src/array/flatten.ts @@ -29,6 +29,13 @@ function _addItems(result: any[], arr: any, d: number): void { /** * The arrFlatten() method returns a new array with all sub-array elements flattened * up to the specified depth (default 1). + * + * Use this helper when the input already contains nested arrays and you only need to + * control how deeply to flatten. Flattening is depth-based and only applies to values + * that are arrays; non-array values are copied into the output unchanged. + * + * For map-then-flatten workflows, use `arrFlatMap()` which always flattens mapped + * results by exactly one level. * @function * @since 0.14.0 * @group Array diff --git a/lib/src/index.ts b/lib/src/index.ts index 33fb73a..aaf8d29 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -7,7 +7,7 @@ */ export { arrAppend } from "./array/append"; -export { ArrPredicateCallbackFn, ArrPredicateCallbackFn2, ArrMapCallbackFn, ArrFromMapFn } from "./array/callbacks"; +export { ArrPredicateCallbackFn, ArrPredicateCallbackFn2, ArrMapCallbackFn, ArrFlatMapCallbackFn, ArrFromMapFn } from "./array/callbacks"; export { arrAt } from "./array/at"; export { arrChunk } from "./array/chunk"; export { arrCompact } from "./array/compact"; @@ -17,6 +17,7 @@ export { arrDropWhile } from "./array/drop_while"; export { arrEvery, arrFilter } from "./array/every"; export { arrFill } from "./array/fill"; export { arrFind, arrFindIndex, arrFindLast, arrFindLastIndex } from "./array/find"; +export { arrFlatMap } from "./array/flatMap"; export { arrFlatten } from "./array/flatten"; export { arrForEach } from "./array/forEach"; export { arrFrom } from "./array/from"; diff --git a/lib/src/polyfills.ts b/lib/src/polyfills.ts index 9c031ea..58910c9 100644 --- a/lib/src/polyfills.ts +++ b/lib/src/polyfills.ts @@ -28,6 +28,7 @@ import { polyObjIsSealed } from "./polyfills/object/objIsSealed"; import { polyObjHasOwn } from "./object/has_own"; import { polyArrAt } from "./array/at"; import { polyArrFill } from "./array/fill"; +import { polyArrFlatMap } from "./array/flatMap"; import { polyArrWith } from "./array/with"; import { polyStrAt } from "./string/at"; import { polyStrMatchAll } from "./string/match_all"; @@ -80,6 +81,7 @@ import { polyStrMatchAll } from "./string/match_all"; "findIndex": polyArrFindIndex, "findLast": polyArrFindLast, "findLastIndex": polyArrFindLastIndex, + "flatMap": polyArrFlatMap, "with": polyArrWith }; @@ -110,4 +112,4 @@ import { polyStrMatchAll } from "./string/match_all"; }); })(); -export { polyArrAt, polyArrFill, polyArrWith, polyStrReplaceAll }; +export { polyArrAt, polyArrFill, polyArrFlatMap, polyArrWith, polyStrReplaceAll }; diff --git a/lib/test/bundle-size-check.js b/lib/test/bundle-size-check.js index 1efa066..7a05e91 100644 --- a/lib/test/bundle-size-check.js +++ b/lib/test/bundle-size-check.js @@ -7,7 +7,7 @@ const configs = [ { name: "es5-min-full", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 32 * 1024, // 32 kb in bytes + limit: 32.5 * 1024, // 32.5 kb in bytes compress: false }, { @@ -19,7 +19,7 @@ const configs = [ { name: "es5-min-zip", path: "../bundle/es5/umd/ts-utils.min.js", - limit: 12.5 * 1024, // 12.5 kb in bytes + limit: 12.75 * 1024, // 12.75 kb in bytes compress: true }, { diff --git a/lib/test/src/common/array/flatMap.test.ts b/lib/test/src/common/array/flatMap.test.ts new file mode 100644 index 0000000..189a72f --- /dev/null +++ b/lib/test/src/common/array/flatMap.test.ts @@ -0,0 +1,247 @@ +/* + * @nevware21/ts-utils + * https://github.com/nevware21/ts-utils + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "@nevware21/tripwire-chai"; +import * as indexExports from "../../../../src/index"; +import * as polyfillExports from "../../../../src/polyfills"; +import { arrFlatMap, polyArrFlatMap } from "../../../../src/array/flatMap"; + +describe("arrFlatMap", () => { + it("should map and flatten one level", () => { + assert.deepEqual(arrFlatMap([1, 2, 3], (value) => [value, value * 2]), [1, 2, 2, 4, 3, 6]); + }); + + it("should support array-like objects", () => { + const arrayLike = { length: 2, 0: "a", 1: "b" }; + + assert.deepEqual(arrFlatMap(arrayLike, (value, index) => [index, value]), [0, "a", 1, "b"]); + }); + + it("should only flatten one level", () => { + assert.deepEqual(arrFlatMap([1, 2], (value) => [[value, value + 10]] as any), [[1, 11], [2, 12]]); + }); + + it("should throw for null and undefined", () => { + assert.throws(() => arrFlatMap(null as any, (value) => [value])); + assert.throws(() => arrFlatMap(undefined as any, (value) => [value])); + }); + + it("should use undefined as callback this when thisArg is omitted", () => { + const contexts: any[] = []; + + const result = arrFlatMap([1, 2], function(this: any, value) { + contexts.push(this); + return [value]; + }); + + assert.deepEqual(result, [1, 2]); + assert.strictEqual(contexts[0], undefined); + assert.strictEqual(contexts[1], undefined); + }); +}); + +describe("native Array.prototype.flatMap", () => { + it("should flatten arrays and not flatten strings", () => { + if (!Array.prototype.flatMap) { + return; + } + + const source = [1, 2]; + const result = source.flatMap((value) => { + return value === 1 ? ["a", "b"] : "cd" as any; + }); + + assert.deepEqual(result, ["a", "b", "cd"]); + }); + + it("should not flatten non-array array-like callback results", () => { + if (!Array.prototype.flatMap) { + return; + } + + const mappedValue = { length: 2, 0: "x", 1: "y" }; + const result = [1].flatMap(() => mappedValue as any); + + assert.equal(result.length, 1); + assert.strictEqual(result[0], mappedValue); + }); + + it("should ignore Symbol.isConcatSpreadable=true on non-array callback values", () => { + if (!Array.prototype.flatMap) { + return; + } + + const spreadSym = typeof Symbol !== "undefined" && Symbol.isConcatSpreadable; + if (!spreadSym) { + return; + } + + const mappedValue: any = { length: 2, 0: "x", 1: "y" }; + mappedValue[spreadSym] = true; + + const result = [1].flatMap(() => mappedValue); + assert.equal(result.length, 1); + assert.strictEqual(result[0], mappedValue); + }); + + it("should flatten arrays even when Symbol.isConcatSpreadable=false", () => { + if (!Array.prototype.flatMap) { + return; + } + + const spreadSym = typeof Symbol !== "undefined" && Symbol.isConcatSpreadable; + if (!spreadSym) { + return; + } + + const mappedValue: any = ["x", "y"]; + mappedValue[spreadSym] = false; + + const result = [1].flatMap(() => mappedValue); + assert.deepEqual(result, ["x", "y"]); + }); +}); + +describe("polyArrFlatMap", () => { + it("should match native Array.prototype.flatMap for arrays", () => { + const source = [1, 2, 3, 4]; + const callback = (value: number) => { + return value % 2 ? [value, value * 10] : []; + }; + + const polyResult = polyArrFlatMap(source, callback); + const nativeResult = source.flatMap ? source.flatMap(callback) : [1, 10, 3, 30]; + + assert.deepEqual(polyResult, nativeResult); + }); + + it("should respect a falsy thisArg", () => { + const contexts: any[] = []; + + const result = polyArrFlatMap([1], function(this: any, value) { + contexts.push(this); + return [value]; + }, 0); + + assert.deepEqual(result, [1]); + assert.strictEqual(contexts[0], 0); + }); + + it("should skip holes in the source and mapped arrays", () => { + const source = new Array(3); + source[1] = 2; + + const result = polyArrFlatMap(source, (value) => { + const mapped = new Array(3); + mapped[1] = value; + return mapped; + }); + + assert.deepEqual(result, [2]); + }); + + it("should not flatten non-array array-like callback results", () => { + const mappedValue = { length: 2, 0: "x", 1: "y" }; + const result = polyArrFlatMap([1], () => mappedValue as any); + + assert.equal(result.length, 1); + assert.strictEqual(result[0], mappedValue); + }); + + it("should not flatten strings returned by callback", () => { + const source = ["a", "b"]; + const callback = (value: string) => value + value; + + const polyResult = polyArrFlatMap(source, callback as any); + const nativeResult = source.flatMap ? source.flatMap(callback as any) : ["aa", "bb"]; + + assert.deepEqual(polyResult, nativeResult); + }); + + it("should ignore Symbol.isConcatSpreadable=true on non-array callback values", () => { + const spreadSym = typeof Symbol !== "undefined" && Symbol.isConcatSpreadable; + if (!spreadSym) { + return; + } + + const mappedValue: any = { length: 2, 0: "x", 1: "y" }; + mappedValue[spreadSym] = true; + + const result = polyArrFlatMap([1], () => mappedValue); + assert.equal(result.length, 1); + assert.strictEqual(result[0], mappedValue); + }); + + it("should flatten arrays even when Symbol.isConcatSpreadable=false", () => { + const spreadSym = typeof Symbol !== "undefined" && Symbol.isConcatSpreadable; + if (!spreadSym) { + return; + } + + const mappedValue: any = ["x", "y"]; + mappedValue[spreadSym] = false; + + const result = polyArrFlatMap([1], () => mappedValue); + assert.deepEqual(result, ["x", "y"]); + }); + + it("should match native behavior for spreadability edge cases", () => { + const spreadSym = typeof Symbol !== "undefined" && Symbol.isConcatSpreadable; + if (!Array.prototype.flatMap || !spreadSym) { + return; + } + + const nonArraySpreadable: any = { 0: "n", 1: "a", length: 2 }; + nonArraySpreadable[spreadSym] = true; + + const arrayNonSpreadable: any = ["a", "r"]; + arrayNonSpreadable[spreadSym] = false; + + const source = [1, 2]; + const callback = (value: number) => { + return value === 1 ? nonArraySpreadable : arrayNonSpreadable; + }; + + const polyResult = polyArrFlatMap(source, callback); + const nativeResult = source.flatMap(callback); + + assert.deepEqual(polyResult, nativeResult); + }); + + it("should use undefined as callback this when thisArg is omitted", () => { + const contexts: any[] = []; + const source = [4, 5]; + + const result = polyArrFlatMap(source, function(this: any, value, index, array) { + contexts.push(this); + assert.strictEqual(array, source); + return [value + (index || 0)]; + }); + + assert.deepEqual(result, [4, 6]); + assert.strictEqual(contexts[0], undefined); + assert.strictEqual(contexts[1], undefined); + }); + + it("should throw for null, undefined and non-function callbacks", () => { + assert.throws(() => polyArrFlatMap(null as any, () => []), TypeError); + assert.throws(() => polyArrFlatMap(undefined as any, () => []), TypeError); + assert.throws(() => polyArrFlatMap([1, 2, 3], null as any), TypeError); + }); +}); + +describe("arrFlatMap exports", () => { + it("should export arrFlatMap from the main index only", () => { + assert.strictEqual(indexExports.arrFlatMap, arrFlatMap); + assert.isUndefined((indexExports as any).polyArrFlatMap); + }); + + it("should export polyArrFlatMap from the polyfills entry", () => { + assert.strictEqual((polyfillExports as any).polyArrFlatMap, polyArrFlatMap); + }); +}); \ No newline at end of file