Repository: remeda/remeda (ts)

Issue:
Create more specific return type for mergeAll when the argument is an array
Currently the return type for mergeAll when used on an array is [object](https://github.com/remeda/remeda/blob/4ef05046809d990a6d0060b56a58d0d1ad4b28ce/src/mergeAll.ts#L20)

[Playground for following snippets
](https://www.typescriptlang.org/play/?#code/JYWwDg9gTgLgBAKjgQwM5wEpwGZQiOAIigFMQSATZQgKFEljhgE8wS4BvAZQAtlSKAVQB2wCMIBiwEgBsKqACIkSYOAF8ceAoRZsAtNhKoYhANw0au9oNQkoAdWAweABR7j2AXk5xgFAFxwxlDAwgDmpnBg7sIkgcIAriAARnbq5lZwNnaOzm4eAIKoXDAh4XDeHL4BQaWhEVExcbVlYemWrNa2Dk48AHLI5BU+foHB9ZHCg83j4ZEQYDDAyFMyACpOMiQA-GN15WrmNADG4sYoUFDIzMMA2gC6KOgFl9cAPNk9eU1wAD5Z3VyriaRRKrT+AJyvQG5AAfEdMhgjAkZDA1p1hhwaHBqntWuYcdEPLsWvUIYkUnYCXApuQSbMIti4AslitkOtNjs8RMaIcLKdhOdSKgUTB-EiRaj0WxhhgAHTkKBhEgFGQyAAU-CuzAAlEcBedFcrVTI+iQAO6yhV2Y1qp5wN5rWHqpla66BJHICjiGTMF7ax2wmg6iqwuBrOAAMjgvH4lBEYkk0jkimUYDeSNOUAobxceDYsGYAGkSMwADRwBLCCgkbChShh6NOo4nM7wWLmiWi8XIqUY7xGlVqs3mzWvXVAA)
I would expect the following to work:

```typescript
type UserWithPhone = { id: string; phone: number };
type UserWithPhoneAsString = { id: string; phone: string };
type UserWithName = { id: string; name: string; optianalTitle?: string };

const array = [] as Array<UserWithPhone | UserWithPhoneAsString | UserWithName>;

type ResultType = {
  id: string;
  phone?: string | number;
  name?: string;
  optianalTitle?: string;
};

const result:ResultType = R.mergeAll(array);
```

After some trial an error i came to this

```typescript
const result:ResultType = R.mergeAll(array);

const mergeAllNew = R.mergeAll as <T>(
  array: ReadonlyArray<T>
) => T & SharedUnionFieldsDeep<Record<PropertyKey, undefined> & T>;


const newResult:ResultType = mergeAllNew(array)
```

One problem with that is that optional types like name are explicitly typed as undefined which is wrong.

I am willing to work on this but have to admit that i might not have the  TypeScript skills yet.

Help or feedback would be highly appreciated.

  






mergeAll tuple overload signatures are unsafe
The runtime behavior of `mergeAll` shallowly merges array contents into a new object. Although it is difficult to produce a perfectly accurate signature for merging in general, the current implementation can produce incorrect return types that mislead the consumer into triggering runtime exceptions for a fairly common scenario: nested objects. 

When objects are intersected, shared fields will also be intersected recursively. Due to the mismatch in depth, nested objects in the result can be incorrectly inferred as having a field that they do not actually have at runtime. See the example test for the 2-item tuple overload below.

```typescript
describe("invalid tuple overload signature", () => {
  type InnerA = { firstField: string };
  type A = { inner: InnerA };
  type InnerB = { secondField: string };
  type B = { inner: InnerB };
  const obj1: A = { inner: { firstField: "obj1" } };
  const obj2: B = { inner: { secondField: "obj2" } };
  const input: [A, B] = [obj1, obj2] as const;

  const result: A & B = mergeAll(input);
  const inner: InnerA & InnerB = result.inner;
  const firstField: string = inner.firstField;
  const secondField: string = inner.secondField;

  it("should have both firstField and secondField", () => {
    expect.soft(secondField).toBe("obj2");
    expect.soft(firstField).toBe("obj1");
  });

  it("firstField should not be undefined", () => {
    expect(firstField).toBeDefined();
  });
});

```

```bash
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 FAIL  src/mergeAll.test.ts > invalid tuple overload signature > should have both firstField and secondField
AssertionError: expected undefined to be 'obj1' // Object.is equality

- Expected:
"obj1"

+ Received:
undefined

 ❯ src/mergeAll.test.ts:38:29
     36|   it("should have both firstField and secondField", () => {
     37|     expect.soft(secondField).toBe("obj2");
     38|     expect.soft(firstField).toBe("obj1");
       |                             ^
     39|   });
     40|

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/2]⎯

 FAIL  src/mergeAll.test.ts > invalid tuple overload signature > firstField should not be undefined
AssertionError: expected undefined to be defined
 ❯ src/mergeAll.test.ts:42:24
     40|
     41|   it("firstField should not be undefined", () => {
     42|     expect(firstField).toBeDefined();
       |                        ^
     43|   });
     44| });

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/2]⎯

 Test Files  1 failed (1)
      Tests  2 failed | 2 passed (4)
   Start at  17:50:54
   Duration  1.36s (transform 58ms, setup 0ms, collect 34ms, tests 7ms, environment 0ms, prepare 487ms)
```

At the very least, the return type should be changed to use `Merge` from type-fest instead of intersection which should handle this scenario. It has at least as much safety as intersection for most other edge cases like arrays containing both objects and primitives. No one would realistically attempt to create this as a tuple and merge it though.

Pull request description:
feat(mergeAll): Create more specific return type for mergeAll


Fixes #918 
Fixes #1006

- The return type for arrays is now a narrowed object encapsulating the possible fields and value types that can appear after merging. There is further type narrowing if the array is known to be nonempty.
- The return type for tuples now correctly resolves the final, specific type of overridden fields during merging. 
- The function now accepts tuples of any size.
- Added internal helper types: 
  - `DisjointUnionFields`

---

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 `docs/src/content/mapping` for both Lodash and Ramda.

---

<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.
- `test`: tests-only changes (transparent to users of the function).
- `docs`: changes to the documentation of a function **or the documentation site**.
- `build`, `ci`, `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/src/internal/types/DisjointUnionFields.ts b/src/internal/types/DisjointUnionFields.ts
new file mode 100644
index 0000000..7e5eed2
--- /dev/null
+++ b/src/internal/types/DisjointUnionFields.ts
@@ -0,0 +1,18 @@
+import type { KeysOfUnion, SharedUnionFields } from "type-fest";
+
+/**
+ * Gets the set of keys that are not shared by all members of a union. This is the complement of the keys of {@link SharedUnionFields}.
+ */
+type DisjointUnionFieldKeys<T extends object> = Exclude<
+  KeysOfUnion<T>,
+  keyof SharedUnionFields<T>
+>;
+
+/**
+ * Gets the set of fields that are not shared by all members of a union. This is the complement of {@link SharedUnionFields}.
+ */
+export type DisjointUnionFields<T extends object> = {
+  [K in DisjointUnionFieldKeys<T>]: T extends Partial<Record<K, unknown>> // if T contains K (distributed, can be optional)
+    ? T[K]
+    : never;
+};
diff --git a/src/mergeAll.ts b/src/mergeAll.ts
index 5e87f93..5440a15 100644
--- a/src/mergeAll.ts
+++ b/src/mergeAll.ts
@@ -1,28 +1,66 @@
+import type {
+  EmptyObject,
+  Merge,
+  SharedUnionFields,
+  Simplify,
+} from "type-fest";
+import type { DisjointUnionFields } from "./internal/types/DisjointUnionFields";
+import type { IterableContainer } from "./internal/types/IterableContainer";
+import type { NonEmptyArray } from "./internal/types/NonEmptyArray";
+import type { TupleParts } from "./internal/types/TupleParts";
+
+/**
+ * Merge a tuple of object types, where props from later objects override earlier props.
+ */
+type MergeTuple<
+  T extends IterableContainer,
+  Result = object, // no-op for the first iteration in the successive merges, also infers object as type by default if an empty tuple is used
+> = T extends readonly [infer Head, ...infer Rest]
+  ? MergeTuple<Rest, Merge<Result, Head>>
+  : Result;
+
+// In the context of a heterogeneous array, the array may not have objects from every type of the union.
+// This means some fields may be missing in the final object, so we make them optional.
+// If there is a non-optional field shared among all members of the union, then we know that if the array is not empty, the field must be present and avoid becoming optional.
+// If the field is already optional, this doesn't really matter.
+// If the array is empty, we know that the loop won't run so we'll just get the empty object.
+// Since we don't know the order of the items in the array, when we merge common fields, we don't know what the final type for the field will be, but we do know that it is one of the many possible types that are available across the members of the union for that field.
+// We represent these possibilities by combining the field's different types across the union members into a union.
+type MergeUnion<T extends object> = Simplify<
+  SharedUnionFields<T> & Partial<DisjointUnionFields<T>>
+>;
+
+type MergeAll<T extends IterableContainer<object>> =
+  // determine if it's a tuple or array
+  TupleParts<T> extends { item: never }
+    ? T extends readonly []
+      ? EmptyObject
+      : MergeTuple<T>
+    : MergeUnion<T[number]> | EmptyObject;
+
 /**
  * Merges a list of objects into a single object.
  *
- * @param array - The array of objects.
+ * @param objects - The array of objects.
+ * @returns A new object merged with all of the objects in the list. If the list is empty, an empty object is returned.
  * @signature
  *    R.mergeAll(objects)
  * @example
  *    R.mergeAll([{ a: 1, b: 1 }, { b: 2, c: 3 }, { d: 10 }]) // => { a: 1, b: 2, c: 3, d: 10 }
+ *    R.mergeAll([]) // => {}
+ * @dataFirst
  * @category Array
  */
-export function mergeAll<A>(array: readonly [A]): A;
-export function mergeAll<A, B>(array: readonly [A, B]): A & B;
-export function mergeAll<A, B, C>(array: readonly [A, B, C]): A & B & C;
-export function mergeAll<A, B, C, D>(
-  array: readonly [A, B, C, D],
-): A & B & C & D;
-export function mergeAll<A, B, C, D, E>(
-  array: readonly [A, B, C, D, E],
-): A & B & C & D & E;
-export function mergeAll(array: ReadonlyArray<object>): object;
-
-export function mergeAll(items: ReadonlyArray<object>): object {
+export function mergeAll<T extends object>(
+  objects: Readonly<NonEmptyArray<T>>,
+): MergeUnion<T>;
+export function mergeAll<T extends IterableContainer<object>>(
+  objects: T,
+): MergeAll<T>;
+export function mergeAll(objects: ReadonlyArray<object>): object {
   let out = {};
 
-  for (const item of items) {
+  for (const item of objects) {
     out = { ...out, ...item };
   }
 


Test patch:
diff --git a/src/internal/types/DisjointUnionFields.test-d.ts b/src/internal/types/DisjointUnionFields.test-d.ts
new file mode 100644
index 0000000..8dc0a65
--- /dev/null
+++ b/src/internal/types/DisjointUnionFields.test-d.ts
@@ -0,0 +1,11 @@
+import type { DisjointUnionFields } from "./DisjointUnionFields";
+
+it("should have the complement of SharedUnionFields", () => {
+  expectTypeOf<
+    DisjointUnionFields<
+      | { a: string; b: string; c: string }
+      | { a: string; b: string; c: number }
+      | { a: string; b: string; d: string }
+    >
+  >().toEqualTypeOf<{ c: string | number; d: string }>();
+});
diff --git a/src/mergeAll.test-d.ts b/src/mergeAll.test-d.ts
new file mode 100644
index 0000000..1fe8052
--- /dev/null
+++ b/src/mergeAll.test-d.ts
@@ -0,0 +1,175 @@
+import type { EmptyObject } from "type-fest";
+import { mergeAll } from "./mergeAll";
+
+describe("arrays", () => {
+  it("custom case", () => {
+    // based on https://github.com/remeda/remeda/issues/918
+
+    const userUnionArray: ReadonlyArray<
+      | { id: string; phone: number }
+      | { id: string; phone: string }
+      | { id: string; name: string; optionalTitle?: string }
+    > = [];
+
+    const mergedUserUnion = mergeAll(userUnionArray);
+
+    expectTypeOf(mergedUserUnion).toEqualTypeOf<
+      | {
+          id: string;
+          phone?: string | number;
+          name?: string;
+          optionalTitle?: string;
+        }
+      | EmptyObject
+    >();
+  });
+
+  it("should produce the same type when the type isn't a union", () => {
+    const input: ReadonlyArray<{ a: string; b: number; c: boolean }> = [];
+    const result = mergeAll(input);
+
+    expectTypeOf(result).toEqualTypeOf<
+      { a: string; b: number; c: boolean } | EmptyObject
+    >();
+  });
+
+  describe("optionality", () => {
+    it("should keep non-optional fields shared across all union members non-optional, with the rest being converted into optionals", () => {
+      // the only real change is c and e becoming optional, because they aren't shared across all union members and they aren't already optional
+
+      const input: ReadonlyArray<
+        | { a?: number; b: string; c: number }
+        | { a?: number; b: string; d?: boolean }
+        | { a?: number; b: string; c: number; e: string }
+      > = [];
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<
+        | { a?: number; b: string; c?: number; d?: boolean; e?: string }
+        | EmptyObject
+      >();
+    });
+
+    it("should preserve undefined in fields when converting fields from non-optional to optional", () => {
+      const input: ReadonlyArray<
+        { a: string } | { a: string; b: string | undefined; c: undefined }
+      > = [];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<
+        { a: string; b?: string | undefined; c?: undefined } | EmptyObject
+      >();
+    });
+
+    it("should prefer optional over non-optional when the same field across all members of the union has different optionalities, because it is type safe", () => {
+      // there is no "optionality union", we should prefer the safer option for these ambiguities
+      const input: ReadonlyArray<
+        { a?: number } | { a: number } | { a?: number; b: string }
+      > = [];
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<
+        { a?: number; b?: string } | EmptyObject
+      >();
+    });
+  });
+
+  describe("should merge different types on same fields into a union", () => {
+    it("when the types are different, they form a union", () => {
+      const input: ReadonlyArray<
+        { a: number; b: string } | { a: string; b: string }
+      > = [];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<
+        { a: string | number; b: string } | EmptyObject
+      >();
+    });
+
+    it("when the fields are unions, they are combined into a single union", () => {
+      const input: ReadonlyArray<
+        { a: number | boolean; b: string } | { a: string | Date; b: string }
+      > = [];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<
+        { a: string | number | boolean | Date; b: string } | EmptyObject
+      >();
+    });
+
+    it("when the field has two different intersections, it becomes the union of the intersections", () => {
+      const input: ReadonlyArray<
+        | { a: { a1: string } & { b1: string }; b: string }
+        | { a: { a2: string } & { b2: string }; b: string }
+      > = [];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<
+        | {
+            a:
+              | ({ a1: string } & { b1: string })
+              | ({ a2: string } & { b2: string });
+            b: string;
+          }
+        | EmptyObject
+      >();
+    });
+  });
+});
+
+describe("nonempty arrays", () => {
+  it("the return type should not include the possibility of returning a nonempty object given a nonempty array with nonempty objects", () => {
+    const input: [
+      { a: number; b: string } | { a: string; b: string },
+      ...ReadonlyArray<{ a: number; b: string } | { a: string; b: string }>,
+    ] = [{ a: 1, b: "b" }];
+
+    const result = mergeAll(input);
+
+    expectTypeOf(result).toEqualTypeOf<{ a: string | number; b: string }>();
+  });
+});
+
+describe("tuples", () => {
+  describe("the fields of the rightmost item should have the greatest priority in overrides", () => {
+    it("0 types", () => {
+      const input: [] = [];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<EmptyObject>();
+    });
+
+    it("1 types", () => {
+      const input: [{ a: 1; b: 1 }] = [{ a: 1, b: 1 }];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<{ a: 1; b: 1 }>();
+    });
+
+    it("2 types", () => {
+      const input: [{ a: 1; b: 1 }, { a: 2 }] = [{ a: 1, b: 1 }, { a: 2 }];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<{ a: 2; b: 1 }>();
+    });
+
+    it("3 types", () => {
+      const input: [{ a: 1; b: 1 }, { a: 2 }, { a: 3 }] = [
+        { a: 1, b: 1 },
+        { a: 2 },
+        { a: 3 },
+      ];
+
+      const result = mergeAll(input);
+
+      expectTypeOf(result).toEqualTypeOf<{ a: 3; b: 1 }>();
+    });
+  });
+});
diff --git a/src/mergeAll.test.ts b/src/mergeAll.test.ts
index f05b811..29a8eff 100644
--- a/src/mergeAll.test.ts
+++ b/src/mergeAll.test.ts
@@ -10,3 +10,11 @@ test("merge objects", () => {
     d: 10,
   });
 });
+
+it("should return an empty object when the input is an empty array", () => {
+  const input: ReadonlyArray<object> = [];
+
+  const result = mergeAll(input);
+
+  expect(result).toStrictEqual({});
+});