Repository: remeda/remeda
Pull request #1145 (2025-07-08 11:27:55)

fix(setPath): support interfaces
Fixes: #990

Everything in typescript is an object (kinda) so we don't need to check it in the condition and instead assume that users aren't doing something weird (but even then try to make it work for them via whatever TypeScript defines as the keyof for that thing).

Additionally we had a minor issue with arrays where a partial path wouldn't match properly because of a missing `readonly` which prevents readonly tuples from satisfying the result.

bolt-new-by-stackblitz[bot]: <a href="https://stackblitz.com/~/github.com/remeda/remeda/pull/1145"><img src="https://developer.stackblitz.com/img/review_pr_small.svg" alt="Review PR in StackBlitz Codeflow" align="left" width="103" height="20" target="_blank"></a> _Run & review this pull request in [StackBlitz Codeflow](https://stackblitz.com/~/github.com/remeda/remeda/pull/1145)._

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/remeda/remeda/pull/1145?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`d7aae6e`)](https://app.codecov.io/gh/remeda/remeda/commit/d7aae6ef54808bc4cd696f22985c1c6b005bb56e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda) to head [(`cc47461`)](https://app.codecov.io/gh/remeda/remeda/commit/cc4746108cc16dcb79276c5286247184f1296de9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main     #1145   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files          170       170           
  Lines         2795      2795           
  Branches       907       907           
=========================================
  Hits          2795      2795           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/remeda/remeda/pull/1145?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=remeda).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/packages/remeda/src/setPath.ts b/packages/remeda/src/setPath.ts
index 80831ca..0411456 100644
--- a/packages/remeda/src/setPath.ts
+++ b/packages/remeda/src/setPath.ts
@@ -1,17 +1,38 @@
+import type { ValueOf } from "type-fest";
+import type { RemedaTypeError } from "./internal/types/RemedaTypeError";
 import { purry } from "./purry";
 
-type Path<T, Prefix extends ReadonlyArray<unknown> = []> =
-  T extends ReadonlyArray<unknown>
-    ? Path<T[number], [...Prefix, number]> | Prefix
-    : T extends Record<PropertyKey, unknown>
-      ? PathsOfObject<T, Prefix> | Prefix
-      : Prefix;
+type Paths<T, Prefix extends ReadonlyArray<unknown> = []> =
+  | Prefix
+  | (T extends object
+      ? ValueOf<{
+          [K in ProperKeyOf<T>]-?: Paths<T[K], [...Prefix, K]>;
+        }>
+      : RemedaTypeError<
+          "setPath",
+          "Can only compute paths objects",
+          { type: never; metadata: T }
+        >) extends infer Path
+  ? // The Paths type is used to define the path param in `setPath`. In order
+    // for both mutable arrays and readonly arrays to be supported we need to
+    // make all results `readonly` (because mutable arrays extend readonly
+    // arrays, but not the other way around). Because the result of Paths is
+    // a union of arrays we need to distribute Result so that the operator is
+    // applied to each member separately.
+    Readonly<Path>
+  : never;
 
-type PathsOfObject<T, Prefix extends ReadonlyArray<unknown>> = {
-  [K in keyof T]-?: Path<T[K], readonly [...Prefix, K]>;
-}[keyof T];
+/**
+ * Array objects have all Array.prototype keys in their "keyof" type, which
+ * is not what we'd expect from the operator. We only want the numeric keys
+ * which represent proper elements of the array.
+ */
+type ProperKeyOf<T> = Extract<
+  keyof T,
+  T extends ReadonlyArray<unknown> ? number : keyof T
+>;
 
-type ValueAtPath<T, TPath> = TPath extends readonly [
+type ValueAtPath<T, Path> = Path extends readonly [
   infer Head extends keyof T,
   ...infer Rest,
 ]
@@ -33,10 +54,10 @@ type ValueAtPath<T, TPath> = TPath extends readonly [
  * @dataFirst
  * @category Object
  */
-export function setPath<T, TPath extends Path<T>>(
+export function setPath<T, Path extends Paths<T>>(
   data: T,
-  path: TPath,
-  value: ValueAtPath<T, TPath>,
+  path: Path,
+  value: ValueAtPath<T, Path>,
 ): T;
 
 /**
@@ -53,10 +74,11 @@ export function setPath<T, TPath extends Path<T>>(
  */
 export function setPath<
   T,
-  TPath extends Path<T>,
-  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- TODO: This is solvable by inlining Value and wrapping the T parameter with `NoInfer` (e.g. `ValueAtPath<NoInfer<T>, TPath>); to prevent typescript from inferring it as `unknown`. This is only available in TS 5.4, which is above what we currently support (5.1).
-  Value extends ValueAtPath<T, TPath>,
->(path: TPath, value: Value): (data: T) => T;
+  Path extends Paths<T>,
+  // TODO [>2] -- TODO: The following eslint is solvable by inlining Value and wrapping the T parameter with `NoInfer` (e.g. `ValueAtPath<NoInfer<T>, TPath>); to prevent typescript from inferring it as `unknown`. This is only available in TS 5.4, which is above what we currently support (5.1).
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- See TODO above...
+  Value extends ValueAtPath<T, Path>,
+>(path: Path, value: Value): (data: T) => T;
 
 export function setPath(...args: ReadonlyArray<unknown>): unknown {
   return purry(setPathImplementation, args);


Test patch:
diff --git a/packages/remeda/src/setPath.test-d.ts b/packages/remeda/src/setPath.test-d.ts
index b817384..fcd22e9 100644
--- a/packages/remeda/src/setPath.test-d.ts
+++ b/packages/remeda/src/setPath.test-d.ts
@@ -12,30 +12,47 @@ declare const TEST_OBJECT: {
   y?: number;
 };
 
-describe("data first", () => {
-  test("should correctly type value argument", () => {
-    // @ts-expect-error [ts2345] - this path should yield a type of number
-    setPath(TEST_OBJECT, ["a", "e", 1, "f", "g"], "hello");
+test("should correctly type value argument", () => {
+  // @ts-expect-error [ts2345] - this path should yield a type of number
+  setPath(TEST_OBJECT, ["a", "e", 1, "f", "g"], "hello");
 
-    // Like this:
-    setPath(TEST_OBJECT, ["a", "e", 1, "f", "g"], 123);
-  });
+  // Like this:
+  setPath(TEST_OBJECT, ["a", "e", 1, "f", "g"], 123);
+});
 
-  test("should correctly type path argument", () => {
-    // @ts-expect-error [ts2322] - 'hello' isn't a valid path
-    setPath(TEST_OBJECT, ["a", "hello"], "hello");
+test("should correctly type path argument", () => {
+  // @ts-expect-error [ts2322] - 'hello' isn't a valid path
+  setPath(TEST_OBJECT, ["a", "hello"], "hello");
 
-    // Like this:
-    setPath(TEST_OBJECT, ["a", "z"], 123);
-  });
+  // Like this:
+  setPath(TEST_OBJECT, ["a", "z"], 123);
+});
 
-  test("should correctly type partial paths", () => {
-    // @ts-expect-error [ts2345] - this path should yield a type of { c: number }
-    setPath(TEST_OBJECT, ["a", "b"] as const, 123);
+test("should correctly type partial paths (objects)", () => {
+  // @ts-expect-error [ts2345] - this path should yield a type of { c: number }
+  setPath(TEST_OBJECT, ["a", "b"] as const, 123);
 
-    // Like this:
-    setPath(TEST_OBJECT, ["a", "b"] as const, { c: 123 });
-  });
+  // Like this:
+  setPath(TEST_OBJECT, ["a", "b"] as const, { c: 123 });
+});
+
+test("should correctly type partial paths (arrays)", () => {
+  const DATA = [[]] as Array<Array<{ c: number }>>;
+
+  // @ts-expect-error [ts2345] - this path should yield a type of { c: number }
+  setPath(DATA, [1, 2] as const, 123);
+
+  // Like this:
+  setPath(DATA, [1, 2] as const, { c: 123 });
+});
+
+test("should correctly type an empty path", () => {
+  // @ts-expect-error [ts2345] - this path should yield a type of
+  // typeof TEST_OBJECT
+  setPath(TEST_OBJECT, [] as const, 123);
+
+  // Like this:
+  setPath(TEST_OBJECT, [] as const, { a: { b: { c: 123 }, e: [] } });
 });
 
 describe("data last", () => {
@@ -63,3 +80,12 @@ describe("data last", () => {
     pipe(TEST_OBJECT, setPath(["a", "b"] as const, { c: 123 }));
   });
 });
+
+// @see https://github.com/remeda/remeda/issues/990
+test("support interfaces (issue #990)", () => {
+  interface User {
+    name: { first: string };
+  }
+
+  setPath({ name: { first: "John" } } as User, ["name", "first"], "Jane");
+});