Repository: marpple/FxTS
Pull request #321 (2025-05-10 14:56:12)

feat: add `transpose`
Fixes https://github.com/marpple/FxTS/issues/319

- [x] Sync test
- [x] Async test
- [ ] Add to `fx`

ppeeou: Thank you for your contribution! I will review it :) 

Patch:
diff --git a/src/Lazy/index.ts b/src/Lazy/index.ts
index a0093060..a1789260 100644
--- a/src/Lazy/index.ts
+++ b/src/Lazy/index.ts
@@ -38,6 +38,7 @@ import takeUntil from "./takeUntil";
 import takeUntilInclusive from "./takeUntilInclusive";
 import takeWhile from "./takeWhile";
 import toAsync from "./toAsync";
+import transpose from "./transpose";
 import uniq from "./uniq";
 import uniqBy from "./uniqBy";
 import values from "./values";
@@ -89,6 +90,7 @@ export {
   toAsync,
   uniq,
   uniqBy,
+  transpose,
   values,
   zip,
   zipWith,
diff --git a/src/Lazy/transpose.ts b/src/Lazy/transpose.ts
new file mode 100644
index 00000000..5b293678
--- /dev/null
+++ b/src/Lazy/transpose.ts
@@ -0,0 +1,137 @@
+import { isAsyncIterable, isIterable, toIterator } from "../_internal/utils";
+import isUndefined from "../isUndefined";
+import max from "../max";
+import nth from "../nth";
+import pipe from "../pipe";
+import size from "../size";
+import toArray from "../toArray";
+import type ReturnZipType from "../types/ReturnZipType";
+import type { UniversalIterable } from "../types/Utils";
+import filter from "./filter";
+import map from "./map";
+import toAsync from "./toAsync";
+
+function* sync(
+  iterable: Iterable<Iterable<unknown>>,
+): IterableIterator<Iterable<unknown>> {
+  const maxLen = pipe(iterable, map(size), max);
+
+  for (let index = 0; index < maxLen; index++) {
+    yield pipe(
+      iterable,
+      map(nth(index)),
+      filter((value) => !isUndefined(value)),
+      toArray,
+    );
+  }
+}
+
+function async(
+  iterable: Iterable<UniversalIterable>,
+): AsyncIterableIterator<UniversalIterable> {
+  const iterators = toArray(map(toIterator, iterable as any));
+
+  return {
+    [Symbol.asyncIterator]() {
+      return this;
+    },
+    async next(_concurrent) {
+      const headIterators = await pipe(
+        toAsync(iterators),
+        map((iterator) => iterator.next(_concurrent)),
+        toArray,
+      );
+
+      const done = headIterators.every(({ done }) => done);
+
+      if (done) return { done: true, value: undefined };
+
+      return {
+        done: false,
+        value: pipe(
+          headIterators,
+          map(({ value }) => value),
+          filter((value) => !isUndefined(value)),
+          toArray,
+        ),
+      };
+    },
+  };
+}
+
+/**
+ * The transpose function takes multiple iterators as input and returns a transposed 2D array—swapping rows with columns.
+ *
+ * Unlike the existing zip function, transpose behaves as shown in the example below:
+ *
+ * @example
+ * ```ts
+ * const iter = transpose([1, 'a'], [2, 'b'], [3, 'c']);
+ * iter.next() // {done:false, value: [1, 2, 3]}
+ * iter.next() // {done:false, value: ['a', 'b', 'c']}
+ * iter.next() // {done:true, value: undefined}
+ *
+ * const iter = transpose([1, 2, 3], ['a', 'b', 'c']);
+ * iter.next() // {done:false, value: [1, 'a']}
+ * iter.next() // {done:false, value: [2, 'b']}
+ * iter.next() // {done:false, value: [3, 'c']}
+ * iter.next() // {done:true, value: undefined}
+ *
+ * // How transpose differs from `zip`
+ * const iter = zip([1, 2], [3], [], [4, 5, 6], [7], [8, 9]);
+ * iter.next() // {done:true, value: undefined}
+ * // Because zip stops at the shortest input, passing an empty array causes it to exit immediately.
+ *
+ * // with pipe
+ * pipe(
+ *   [4, 5, 6, 7],
+ *   transpose([1, 2, 3]),
+ *   toArray,
+ * ) // [[ 1, 4 ], [ 2, 5 ], [ 3, 6 ], [7]]
+ *
+ * pipe(
+ *   Promise.resolve([4, 5, 6, 7]),
+ *   transpose([1, 2, 3]),
+ *   toArray,
+ * ) // [[ 1, 4 ], [ 2, 5 ], [ 3, 6 ], [7]]
+ *
+ * // with toAsync
+ * pipe(
+ *   [Promise.resolve(4), Promise.resolve(5), Promise.resolve(6), Promise.resolve(7)],
+ *   toAsync,
+ *   transpose([1, 2, 3]),
+ *   toArray,
+ * ) // [[ 1, 4 ], [ 2, 5 ], [ 3, 6 ], [7]]
+ * ```
+ */
+
+function transpose<T extends UniversalIterable, R extends UniversalIterable[]>(
+  iterables: T,
+): (...args: R) => ReturnZipType<[T, ...R]>;
+function transpose<T extends UniversalIterable[]>(
+  ...args: T
+): ReturnZipType<[...T]>;
+
+function transpose<R extends UniversalIterable[]>(...iterables: R[]) {
+  if (size(iterables) < 2)
+    return (...args: UniversalIterable[]) =>
+      transpose(...iterables, ...args) as ReturnZipType<R>;
+
+  if (
+    iterables.some((value) => !isIterable(value) && !isAsyncIterable(value))
+  ) {
+    throw new TypeError("'iterable' must be type of Iterable or AsyncIterable");
+  }
+
+  const hasAsyncIterable = iterables.some((iterable) =>
+    isAsyncIterable(iterable),
+  );
+
+  if (hasAsyncIterable) {
+    return async(iterables) as ReturnZipType<R>;
+  }
+
+  return sync(iterables);
+}
+
+export default transpose;


Test patch:
diff --git a/test/Lazy/transpose.spec.ts b/test/Lazy/transpose.spec.ts
new file mode 100644
index 00000000..bd101e12
--- /dev/null
+++ b/test/Lazy/transpose.spec.ts
@@ -0,0 +1,207 @@
+import {
+  concurrent,
+  delay,
+  join,
+  map,
+  pipe,
+  sum,
+  take,
+  toArray,
+  toAsync,
+  transpose,
+} from "../../src/index";
+import { Concurrent } from "../../src/Lazy/concurrent";
+import { generatorMock } from "../utils";
+
+describe("transpose", function () {
+  describe("sync", function () {
+    it("should be merged values of each 'Iterable' with value at the corresponding position", function () {
+      const res = toArray(transpose([1, "a"], [2, "b"], [3, "c"], [4, "d"]));
+
+      expect(res).toEqual([
+        [1, 2, 3, 4],
+        ["a", "b", "c", "d"],
+      ]);
+      expect(toArray(transpose(...res))).toEqual([
+        [1, "a"],
+        [2, "b"],
+        [3, "c"],
+        [4, "d"],
+      ]);
+    });
+
+    it("should be transposed if the iterables have different size", function () {
+      const res = toArray(transpose([1, 2], [3], [], [4, 5, 6], [7], [8, 9]));
+
+      expect(res).toEqual([[1, 3, 4, 7, 8], [2, 5, 9], [6]]);
+    });
+
+    it("should be able to be used in the pipeline", function () {
+      const res = pipe(
+        [1, 5],
+        (a) => transpose(a, [2, 6], [3, 7], [4, 8, 9]),
+        take(2),
+        map(sum),
+        toArray,
+      );
+
+      expect(res).toEqual([10, 26]);
+    });
+
+    it("should be transposed each 'Iterable' having a different type", function () {
+      const res = pipe(
+        ["a", 1],
+        (a) => transpose(a, ["b", 2], ["c", 3]),
+        map((value) => join("-", value)),
+        toArray,
+      );
+
+      expect(res).toEqual(["a-b-c", "1-2-3"]);
+    });
+
+    it("should be able to be used as a curried function in the pipeline", async function () {
+      const res = pipe([5, 6, 7, 8], transpose([1, 2, 3, 4]), toArray);
+
+      expect(res).toEqual([
+        [1, 5],
+        [2, 6],
+        [3, 7],
+        [4, 8],
+      ]);
+    });
+  });
+
+  describe("async", function () {
+    it("should be merged values of each 'AsyncIterable' with value at the corresponding position", async function () {
+      const res = await toArray(
+        transpose(
+          toAsync([1, 5]),
+          toAsync([2, 6]),
+          toAsync([3, 7]),
+          toAsync([4, 8]),
+        ),
+      );
+
+      expect(res).toEqual([
+        [1, 2, 3, 4],
+        [5, 6, 7, 8],
+      ]);
+    });
+
+    it("should be transposed if the iterables have different size", async function () {
+      const res = await toArray(
+        transpose(
+          toAsync([1, 2]),
+          toAsync([3]),
+          toAsync([]),
+          toAsync([4, 5, 6]),
+          toAsync([7]),
+          toAsync([8, 9]),
+        ),
+      );
+
+      expect(res).toEqual([[1, 3, 4, 7, 8], [2, 5, 9], [6]]);
+    });
+
+    it("should be transposed 'Iterable' and 'AsyncIterable'", async function () {
+      const res = await toArray(
+        transpose(
+          [1, 2, 3, 4],
+          // prettier-ignore
+          toAsync([5, 6, 7, 8]),
+        ),
+      );
+
+      expect(res).toEqual([
+        [1, 5],
+        [2, 6],
+        [3, 7],
+        [4, 8],
+      ]);
+    });
+
+    it("should be able to be used in the pipeline", async function () {
+      const res = await pipe(
+        toAsync([1, 2, 3, 4]),
+        (a) => transpose(a, [5, 6, 7, 8]),
+        toArray,
+      );
+
+      expect(res).toEqual([
+        [1, 5],
+        [2, 6],
+        [3, 7],
+        [4, 8],
+      ]);
+    });
+
+    it("should be transposed each 'AsyncIterable' having a different type", async function () {
+      const res = await pipe(
+        ["a", "b", "c", "d"],
+        (a) => transpose(a, toAsync([1, 2, 3, 4])),
+        map((a) => Object.fromEntries([a])),
+        toArray,
+      );
+      expect(res).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }, { d: 4 }]);
+    });
+
+    it("should be transposed sequentially", async function () {
+      const res = await pipe(
+        toAsync([5, 6, 7, 8]),
+        map((nums) => delay(1000, nums)),
+        transpose([1, 2, 3, 4]),
+        toArray,
+      );
+
+      expect(res).toEqual([
+        [1, 5],
+        [2, 6],
+        [3, 7],
+        [4, 8],
+      ]);
+    }, 4050);
+
+    it("should be transposed concurrently: transpose - map", async function () {
+      const res = await pipe(
+        toAsync([5, 6, 7, 8]),
+        transpose([1, 2, 3, 4]),
+        map((nums) => delay(1000, nums)),
+        concurrent(4),
+        toArray,
+      );
+
+      expect(res).toEqual([
+        [1, 5],
+        [2, 6],
+        [3, 7],
+        [4, 8],
+      ]);
+    }, 1050);
+
+    it("should be transposed concurrently: map - transpose", async function () {
+      const res = await pipe(
+        toAsync([5, 6, 7, 8]),
+        map((nums) => delay(1000, nums)),
+        transpose([1, 2, 3, 4]),
+        concurrent(4),
+        toArray,
+      );
+
+      expect(res).toEqual([
+        [1, 5],
+        [2, 6],
+        [3, 7],
+        [4, 8],
+      ]);
+    }, 1050);
+
+    it("should be passed concurrent object when job works concurrently", async function () {
+      const mock = generatorMock();
+      const iter = transpose([1, 2, 3], mock);
+      const concurrent = Concurrent.of(2) as any;
+
+      await iter.next(concurrent);
+      expect((mock as any).getConcurrent()).toEqual(concurrent);
+    });
+  });
+});
diff --git a/type-check/Lazy/transpose.test.ts b/type-check/Lazy/transpose.test.ts
new file mode 100644
index 00000000..5f1d7f5f
--- /dev/null
+++ b/type-check/Lazy/transpose.test.ts
@@ -0,0 +1,26 @@
+import { pipe, toAsync, transpose } from "../../src";
+import * as Test from "../../src/types/Test";
+
+const { checks, check } = Test;
+
+const res1 = transpose([1, 2, 3], "abc", [true, false, true]);
+const res2 = transpose([1, 2, 3], "abcde", [true, false, true]);
+const res3 = transpose([1, 2, 3], toAsync("abc"), [true, false, true]);
+
+const res4 = pipe([1, 2, 3], transpose("abc"));
+const res5 = pipe([1, 2, 3], toAsync, transpose("abc"));
+const res6 = pipe([1, 2, 3], transpose(toAsync("abc")));
+
+checks([
+  check<typeof res1, IterableIterator<[number, string, boolean]>, Test.Pass>(),
+  check<typeof res2, IterableIterator<[number, string, boolean]>, Test.Pass>(),
+  check<
+    typeof res3,
+    AsyncIterableIterator<[number, string, boolean]>,
+    Test.Pass
+  >(),
+
+  check<typeof res4, IterableIterator<[string, number]>, Test.Pass>(),
+  check<typeof res5, AsyncIterableIterator<[string, number]>, Test.Pass>(),
+  check<typeof res6, AsyncIterableIterator<[string, number]>, Test.Pass>(),
+]);