Repository: remeda/remeda (ts)

Issue:
Add `mapWithFeedback` (aka scan, accumulate, partial_sum, runningReduce)
This would be the equivalent of, say, [`scan` from Ramda](https://ramdajs.com/docs/#scan), [`itertools.accumulate` from Python](https://docs.python.org/3.11/library/itertools.html#itertools.accumulate), [`std::partial_sum` from C++](https://en.cppreference.com/w/cpp/algorithm/partial_sum), [`runningReduce` from Kotlin](https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/running-reduce.html).

It would have a similar signature to `R.reduce`, but it would also be pipeable, like `R.map`.

Lots of room for bikeshedding the name.

Pull request description:
feat(mapWithFeedback): Add `mapWithFeedback`
closes #468 

This PR should implement the functionality as specified but there are some issues that may require changes outside the scope of this PR.
 
# Changes
- `mapWithFeedback` implementation
- lazy, lazyIndexed, indexed implementations

# Comments
- Referenced this PR for code style: #445 
- The current implementation uses the same accumulator reference with every iteration. Ramda's scan implementation also has this same behavior so I'm not sure if this is unintended behavior, but it does seem like it'd be quite easy for someone to be caught off guard. Here's an example below.
```typescript
 it("should use the same accumulator on every iteration if it's mutable, therefore returning an array containing {array length} references to the accumulator.", () => {
      const results = mapWithFeedback(
        [1, 2, 3, 4, 5] as const,
        (acc, x) => {
          acc[x] = x;
          return acc;
        },
        {} as Record<string,any>
      );
      const expectedEquality = new Array(5).fill({ '1': 1, '2': 2, '3': 3, '4': 4, '5': 5}) // as opposed to [{ '1': 1 },{ '1': 1, '2': 2 },{ '1': 1, '2': 2, '3': 3 },{ '1': 1, '2': 2, '3': 3, '4': 4 },{ '1': 1, '2': 2, '3': 3, '4': 4, '5': 5 }]
      expect(results).toEqual(expectedEquality);
      const item = results[0];
      expect(results.every(result => result === item)).toBe(true)
    });
```
- Using mapWithFeedback.indexed (or reduce.indexed) with other transformer functions will throw an error. A fix may require changes to the `purry` implementation which would be outside of the scope of this PR.
```typescript
    it("should be compatible with transformer functions", () => {
      expect(() => {
        const result = mapWithFeedback(
          [1,2,3,4,5],
          add,
          100,
        )
        expect(result).toEqual([101, 103, 106, 110, 115])
      }).not.toThrowError("Wrong number of arguments")
    })

    it("indexed should not be compatible with transformer functions", () => {
      expect(() => {
        const result = mapWithFeedback.indexed(
          [1,2,3,4,5],
          add,
          100,
        )
        expect(result).toEqual([101, 103, 106, 110, 115])
      }).toThrowError("Wrong number of arguments")
    })
```
---

Make sure that you:

- [x] Typedoc added for new methods and updated for changed
- [x] Tests added for new methods and updated for changed
- [x] New methods added to `src/index.ts`
- [x] New methods added to `mapping.md`

---

<details><summary>We use semantic PR titles to automate the release process!</summary>

https://conventionalcommits.org

PRs should be titled following using the format: `< TYPE >(< scope >)?: description`

### Available Types:

- `feat`: new functions, and changes to a function's type that would impact users.
- `fix`: changes to the runtime behavior of an existing function, or refinements to it's type that shouldn't impact most users.
- `perf`: changes to function implementations that improve a functions _runtime_ performance.
- `refactor`: changes to function implementations that are neither `fix` nor `perf`
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `style`, `chore`, and `revert`: are only relevant for the internals of the library.

For scope put the name of the function you are working on (either new or
existing).

</details>


Patch:
diff --git a/mapping.md b/mapping.md
index ddeeade..52f57b0 100644
--- a/mapping.md
+++ b/mapping.md
@@ -59,6 +59,7 @@ documentation when migrating._
 | [`map`](https://remedajs.com/docs/#map)                             | [`map`](https://lodash.com/docs/4.17.15#map)                             | [`map`](https://ramdajs.com/docs/#map)                             |
 | [`mapKeys`](https://remedajs.com/docs/#mapKeys)                     | [`mapKeys`](https://lodash.com/docs/4.17.15#mapKeys)                     |                                                                    |
 | [`mapValues`](https://remedajs.com/docs/#mapValues)                 | [`mapValues`](https://lodash.com/docs/4.17.15#mapValues)                 | [`mapValues`](https://ramdajs.com/docs/#mapObjIndexed)             |
+| [`mapWithFeedback`](https://remedajs.com/docs/#mapWithFeedback)     |                                                                          | [`scan`](https://ramdajs.com/docs/#scan)                           |
 | [`meanBy`](https://remedajs.com/docs/#meanBy)                       | [`meanBy`](https://lodash.com/docs/4.17.15#meanBy)                       |                                                                    |
 | [`merge`](https://remedajs.com/docs/#merge)                         | [`assign`](https://lodash.com/docs/4.17.15#assign)                       | [`merge`](https://ramdajs.com/docs/#merge)                         |
 | [`mergeAll`](https://remedajs.com/docs/#mergeAll)                   | [`assign`](https://lodash.com/docs/4.17.15#assign)                       | [`mergeAll`](https://ramdajs.com/docs/#mergeAll)                   |
diff --git a/src/_types.ts b/src/_types.ts
index ba3c25a..fd03e5f 100644
--- a/src/_types.ts
+++ b/src/_types.ts
@@ -14,6 +14,10 @@ export type PredIndexedOptional<T, K> = (
 
 export type NonEmptyArray<T> = [T, ...Array<T>];
 
+export type Mapped<T extends IterableContainer, K> = {
+  -readonly [P in keyof T]: K;
+};
+
 /**
  * This should only be used for defining generics which extend any kind of JS
  * array under the hood, this includes arrays *AND* tuples (of the form [x, y],
diff --git a/src/index.ts b/src/index.ts
index d52a948..073aa8d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -70,6 +70,7 @@ export * from "./map";
 export * from "./mapKeys";
 export * from "./mapToObj";
 export * from "./mapValues";
+export * from "./mapWithFeedback";
 export * from "./maxBy";
 export * from "./meanBy";
 export * from "./merge";
diff --git a/src/map.ts b/src/map.ts
index 3da688c..7f01f42 100644
--- a/src/map.ts
+++ b/src/map.ts
@@ -2,6 +2,7 @@ import { _reduceLazy } from "./_reduceLazy";
 import { _toLazyIndexed } from "./_toLazyIndexed";
 import type {
   IterableContainer,
+  Mapped,
   Pred,
   PredIndexed,
   PredIndexedOptional,
@@ -77,28 +78,24 @@ type Strict = {
   <T extends IterableContainer, K>(
     items: T,
     mapper: Pred<T[number], K>,
-  ): StrictOut<T, K>;
+  ): Mapped<T, K>;
 
   <T extends IterableContainer, K>(
     mapper: Pred<T[number], K>,
-  ): (items: T) => StrictOut<T, K>;
+  ): (items: T) => Mapped<T, K>;
 
   readonly indexed: {
     <T extends IterableContainer, K>(
       items: T,
       mapper: PredIndexed<T[number], K>,
-    ): StrictOut<T, K>;
+    ): Mapped<T, K>;
 
     <T extends IterableContainer, K>(
       mapper: PredIndexed<T[number], K>,
-    ): (items: T) => StrictOut<T, K>;
+    ): (items: T) => Mapped<T, K>;
   };
 };
 
-type StrictOut<T extends IterableContainer, K> = {
-  -readonly [P in keyof T]: K;
-};
-
 export namespace map {
   export function indexed<T, K>(
     array: ReadonlyArray<T>,
diff --git a/src/mapWithFeedback.ts b/src/mapWithFeedback.ts
new file mode 100644
index 0000000..e028c99
--- /dev/null
+++ b/src/mapWithFeedback.ts
@@ -0,0 +1,131 @@
+import { _reduceLazy } from "./_reduceLazy";
+import { _toLazyIndexed } from "./_toLazyIndexed";
+import type { IterableContainer, Mapped } from "./_types";
+import type { LazyEvaluator } from "./pipe";
+import { purry } from "./purry";
+
+/**
+ * Applies a function on each element of the array, using the result of the previous application, and returns an array of the successively computed values.
+ * @param array the array to map over
+ * @param reducer the callback function that receives the previous value, the current element, and optionally the index and the whole array
+ * @param initialValue the initial value to start the computation with
+ * @returns An array of successively computed values from the left side of the array.
+ * @signature
+ *    R.mapWithFeedback(items, fn, initialValue)
+ *    R.mapWithFeedback.indexed(items, fn, initialValue)
+ * @example
+ *    R.mapWithFeedback([1, 2, 3, 4, 5], (prev, x) => prev + x, 100) // => [101, 103, 106, 110, 115]
+ *    R.mapWithFeedback.indexed([1, 2, 3, 4, 5], (prev, x, i, array) => prev + x, 100) // => [101, 103, 106, 110, 115]
+ * @dataFirst
+ * @indexed
+ * @pipeable
+ * @category Array
+ */
+export function mapWithFeedback<T extends IterableContainer, U>(
+  array: T,
+  reducer: (previousValue: U, currentValue: T[number]) => U,
+  initialValue: U,
+): Mapped<T, U>;
+
+/**
+ * Applies a function on each element of the array, using the result of the previous application, and returns an array of the successively computed values.
+ * @param reducer the callback function that receives the previous value, the current element, and optionally the index and the whole array
+ * @param initialValue the initial value to start the computation with
+ * @returns An array of successively computed values from the left side of the array.
+ * @signature
+ *    R.mapWithFeedback(fn, initialValue)(array)
+ * @example
+ *    R.pipe([1, 2, 3, 4, 5], R.mapWithFeedback((prev, x) => prev + x, 100)) // => [101, 103, 106, 110, 115]
+ *    R.pipe([1, 2, 3, 4, 5], R.mapWithFeedback.indexed((prev, x, i, array) => prev + x, 100)) // => [101, 103, 106, 110, 115]
+ * @dataLast
+ * @indexed
+ * @pipeable
+ * @category Array
+ */
+export function mapWithFeedback<T extends IterableContainer, U>(
+  reducer: (previousValue: U, currentValue: T[number]) => U,
+  initialValue: U,
+): (items: T) => Mapped<T, U>;
+
+export function mapWithFeedback(): unknown {
+  return purry(
+    mapWithFeedbackImplementation(false),
+    arguments,
+    mapWithFeedback.lazy,
+  );
+}
+
+const mapWithFeedbackImplementation =
+  (indexed: boolean) =>
+  <T, U>(
+    items: ReadonlyArray<T>,
+    reducer: (
+      previousValue: U,
+      currentValue: T,
+      index?: number,
+      items?: ReadonlyArray<T>,
+    ) => U,
+    initialValue: U,
+  ) => {
+    const implementation = indexed
+      ? mapWithFeedback.lazyIndexed
+      : mapWithFeedback.lazy;
+
+    return _reduceLazy(items, implementation(reducer, initialValue), indexed);
+  };
+
+const lazyImplementation =
+  (indexed: boolean) =>
+  <T, U>(
+    reducer: (
+      previousValue: U,
+      currentValue: T,
+      index?: number,
+      items?: ReadonlyArray<T>,
+    ) => U,
+    initialValue: U,
+  ): LazyEvaluator<T, U> => {
+    let previousValue = initialValue;
+    return (value, index, items) => {
+      previousValue = indexed
+        ? reducer(previousValue, value, index, items)
+        : reducer(previousValue, value);
+      return {
+        done: false,
+        hasNext: true,
+        next: previousValue,
+      };
+    };
+  };
+
+export namespace mapWithFeedback {
+  export function indexed<T extends IterableContainer, U>(
+    items: T,
+    reducer: (
+      previousValue: U,
+      currentValue: T[number],
+      index: number,
+      items: T,
+    ) => U,
+    initialValue: U,
+  ): Mapped<T, U>;
+  export function indexed<T extends IterableContainer, U>(
+    reducer: (
+      previousValue: U,
+      currentValue: T[number],
+      index: number,
+      items: T,
+    ) => U,
+    initialValue: U,
+  ): (items: T) => Mapped<T, U>;
+  export function indexed(): unknown {
+    return purry(
+      mapWithFeedbackImplementation(true),
+      arguments,
+      mapWithFeedback.lazyIndexed,
+    );
+  }
+
+  export const lazy = lazyImplementation(false);
+  export const lazyIndexed = _toLazyIndexed(lazyImplementation(true));
+}


Test patch:
diff --git a/src/mapWithFeedback.test.ts b/src/mapWithFeedback.test.ts
new file mode 100644
index 0000000..886c63f
--- /dev/null
+++ b/src/mapWithFeedback.test.ts
@@ -0,0 +1,221 @@
+import { pipe } from "./pipe";
+import { mapWithFeedback } from "./mapWithFeedback";
+import { createLazyInvocationCounter } from "../test/lazy_invocation_counter";
+import { take } from "./take";
+import { add } from "./add";
+
+describe("data first", () => {
+  describe("base", () => {
+    it("should return an array of successively accumulated values", () => {
+      const result = mapWithFeedback(
+        [1, 2, 3, 4, 5] as const,
+        (acc, x) => acc + x,
+        100,
+      );
+      expect(result).toEqual([101, 103, 106, 110, 115]);
+    });
+
+    it("should use the same accumulator on every iteration if it's mutable, therefore returning an array containing {array length} references to the accumulator.", () => {
+      const results = mapWithFeedback(
+        [1, 2, 3, 4, 5] as const,
+        (acc, x) => {
+          acc[x] = x;
+          return acc;
+        },
+        {} as Record<string, unknown>,
+      );
+
+      const [item] = results;
+      expect(item).toEqual({ "1": 1, "2": 2, "3": 3, "4": 4, "5": 5 });
+      for (const result of results) {
+        expect(result).toBe(item);
+      }
+    });
+
+    it("if an empty array is provided, it should never iterate, returning a new empty array.", () => {
+      const data: Array<unknown> = [];
+      const result = mapWithFeedback(data, (acc) => acc, "value");
+      expect(result).toEqual([]);
+      expect(result).not.toBe(data);
+    });
+
+    it("should be compatible with transformer functions", () => {
+      expect(() => {
+        const result = mapWithFeedback([1, 2, 3, 4, 5], add, 100);
+        expect(result).toEqual([101, 103, 106, 110, 115]);
+      }).not.toThrowError("Wrong number of arguments");
+    });
+
+    it("indexed should not be compatible with transformer functions", () => {
+      expect(() => {
+        const result = mapWithFeedback.indexed([1, 2, 3, 4, 5], add, 100);
+        expect(result).toEqual([101, 103, 106, 110, 115]);
+      }).toThrowError("Wrong number of arguments");
+    });
+
+    describe("type test", () => {
+      it("should return a mutable tuple type whose length matches input container's length, consisting of the type of the initial value", () => {
+        const result = mapWithFeedback(
+          [1, 2, 3, 4, 5] as const,
+          (acc, x) => acc + x,
+          100,
+        );
+        expectTypeOf(result).toEqualTypeOf<
+          [number, number, number, number, number]
+        >();
+      });
+
+      it("should return a tuple consisting of the initial value type even if the initial iterable contains a different type", () => {
+        const result = mapWithFeedback(
+          ["1", "2", "3", "4", "5"] as const,
+          (acc, x) => acc + parseInt(x),
+          100,
+        );
+        expectTypeOf(result).toEqualTypeOf<
+          [number, number, number, number, number]
+        >();
+      });
+
+      it("should correctly infer type with a non-literal array type", () => {
+        const data: Array<number> = [1, 2, 3, 4, 5];
+        const result = mapWithFeedback(data, (acc, x) => acc + x, 100);
+        expectTypeOf(result).toEqualTypeOf<Array<number>>();
+      });
+    });
+  });
+
+  describe("indexed", () => {
+    it("should track index and provide entire items array", () => {
+      const data = [1, 2, 3, 4, 5] as const;
+      const mockedReducer = vi.fn(
+        (
+          acc: number,
+          x: number,
+          _index: number,
+          _items: ReadonlyArray<number>,
+        ): number => acc + x,
+      );
+      const expected: Array<Parameters<typeof mockedReducer>> = [
+        [100, 1, 0, [1, 2, 3, 4, 5]],
+        [101, 2, 1, [1, 2, 3, 4, 5]],
+        [103, 3, 2, [1, 2, 3, 4, 5]],
+        [106, 4, 3, [1, 2, 3, 4, 5]],
+        [110, 5, 4, [1, 2, 3, 4, 5]],
+      ];
+
+      mapWithFeedback.indexed(data, mockedReducer, 100);
+
+      expect(mockedReducer.mock.calls).toEqual(expected);
+    });
+    it("should return array of successively accumulated values", () => {
+      const result = mapWithFeedback.indexed(
+        [1, 2, 3, 4, 5] as const,
+        (acc, x) => acc + x,
+        100,
+      );
+      expect(result).toEqual([101, 103, 106, 110, 115]);
+    });
+
+    describe("type test", () => {
+      it("should return a mutable tuple type whose length matches input container's length, consisting of the type of the initial value", () => {
+        const result = mapWithFeedback.indexed(
+          [1, 2, 3, 4, 5] as const,
+          (acc, x) => acc + x,
+          100,
+        );
+        expectTypeOf(result).toEqualTypeOf<
+          [number, number, number, number, number]
+        >();
+      });
+
+      it("the items array passed to the callback should be an array type containing the union type of all of the members in the original array", () => {
+        mapWithFeedback.indexed(
+          [1, 2, 3, 4, 5] as const,
+          (acc, x, _index, items) => {
+            expectTypeOf(items).toEqualTypeOf<readonly [1, 2, 3, 4, 5]>();
+            return acc + x;
+          },
+          100,
+        );
+      });
+    });
+  });
+});
+
+describe("data last", () => {
+  describe("base", () => {
+    it("should return an array of successively accumulated values", () => {
+      expect(
+        pipe(
+          [1, 2, 3, 4, 5] as const,
+          mapWithFeedback((acc, x) => acc + x, 100),
+        ),
+      ).toEqual([101, 103, 106, 110, 115]);
+    });
+
+    it("evaluates lazily", () => {
+      const counter = createLazyInvocationCounter();
+      pipe(
+        [1, 2, 3, 4, 5] as const,
+        counter.fn(),
+        mapWithFeedback((acc, x) => acc + x, 100),
+        take(2),
+      );
+      expect(counter.count).toHaveBeenCalledTimes(2);
+    });
+
+    it("should return a tuple consisting of the initial value type even if the initial iterable contains a different type", () => {
+      const result = pipe(
+        [1, 2, 3, 4, 5] as const,
+        mapWithFeedback((acc, x) => acc + x, 100),
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        [number, number, number, number, number]
+      >();
+    });
+  });
+
+  describe("indexed", () => {
+    it("should track index and progressively include elements from the original array in the items array during each iteration, forming a growing window", () => {
+      const lazyItems: Array<Array<number>> = [];
+      const indices: Array<number> = [];
+      pipe(
+        [1, 2, 3, 4, 5] as const,
+        mapWithFeedback.indexed((acc, x, index, items) => {
+          indices.push(index);
+          lazyItems.push([...items]);
+          return acc + x;
+        }, 100),
+      );
+      expect(indices).toEqual([0, 1, 2, 3, 4]);
+      expect(lazyItems).toEqual([
+        [1],
+        [1, 2],
+        [1, 2, 3],
+        [1, 2, 3, 4],
+        [1, 2, 3, 4, 5],
+      ]);
+    });
+
+    it("evaluates lazily", () => {
+      const counter = createLazyInvocationCounter();
+      pipe(
+        [1, 2, 3, 4, 5] as const,
+        counter.fn(),
+        mapWithFeedback.indexed((acc, x) => acc + x, 100),
+        take(2),
+      );
+      expect(counter.count).toHaveBeenCalledTimes(2);
+    });
+
+    it("should return a tuple consisting of the initial value type even if the initial iterable contains a different type", () => {
+      const result = pipe(
+        [1, 2, 3, 4, 5] as const,
+        mapWithFeedback.indexed((acc, x) => acc + x, 100),
+      );
+      expectTypeOf(result).toEqualTypeOf<
+        [number, number, number, number, number]
+      >();
+    });
+  });
+});