1

Lodash has an object utility called update, and what it does is quite simple:

const obj = {}

_.update(obj, 'a.b.c', prev => prev ? 1 : 0)

console.log(obj) // { a: { b: { c: 0 } } }

See on CodeSandbox →

As you can see, you specify a path (which is a string) to the second argument and the update function will create the path recursively and will set the value of the last item in the path as the value returned by the updater.

However, the typing support is not good:

const obj = { a: { b: { c: "hello world" } } };

_.update(obj, "a.b.c", (prev) => (prev ? 1 : 0));

console.log(obj); // { a: { b: { c: 0 } } }

See on CodeSandbox →

Turns out the type of prev is any, which means not safe at all. Then I considered creating my own solution (an alternative/wrapper to Lodash's update utility) and started researching how to type a path and stumbled upon this answer, however, my greatest difficulty was understanding what was going on there—too much to digest, not to mention what about computed keys?

So I created this wrapper around _.update:

export function mutate<T extends object, P extends object = object>(
  obj: T,
  path: P,
  updater: (prev: ValueOf<T>) => ValueOf<T>
): T {
  const actualPath = Object.keys(path)
    .map((o) => path[o as keyof P])
    .join(".");

  return _.update(obj, actualPath, updater);
}

const obj = { a: { b: { c: 123 } } };

const x = "a";
const y = "b";
const z = "c";

mutate(obj, { x, y, z }, (prev) => 123);

See on CodeSandbox →

And I noticed some progress because mutate is complaining that 123 is not compatible with { b: { c: number } }, but as you can see, it still lacks recursion and I'm still totally confused about how to proceed from here.

Questions

  1. Is it possible to retrieve the type of a computed property, as shown above?
  2. If so, how?
  3. How to address it recursively, if at all possible?
  4. If you know any other library or npm package that addresses this exact problem, specifically with updating an object, I'd appreciate the shoutout.
Guilherme Oderdenge
  • 4,935
  • 6
  • 61
  • 96
  • In the future, please use the [TypeScript playground](https://www.typescriptlang.org/play/?#code/Q). "How do I use external libraries?" That's the cool part. The playground *automatically* tries to import it for you. – kelsny Sep 16 '22 at 00:26
  • Is only the type of `prev` important to you or also the return type of `mutate`? – Tobias S. Sep 16 '22 at 00:29
  • @TobiasS. It seems that they want to change the type of the given object. So something similar to [my earlier answer](https://stackoverflow.com/questions/73724152/how-to-avoid-the-compiler-error-for-enumerable-false-properties/73725288#73725288) using `asserts` is needed. – kelsny Sep 16 '22 at 00:33
  • would [this](https://tsplay.dev/mZj0am) answer your question? If so, I would love to write an answer. Note that the limitation here is that changing the type of the existing `obj` variable is not really possible. The `asserts` approach mentioned by @caTS only works if the resulting type is extending the original type. But if you for example change the type of a property, the new type does not extend the old type anmore. So my solution just returns a new object type from the function. – Tobias S. Sep 16 '22 at 00:50
  • @caTS I didn't know that. Will keep in mind for my next time. Thank you for letting me know! @TobiasS. The return type of `mutate` not so much. It'd be nice, yes, not essential. `prev` is definitely more important. And your answer seems to be working fine with explicit path strings, but do you think it's possible, given your solution, to adapt it to get the return type of each computed key? Not asking you the solution right away - just if it is at all possible, according to the third CodeSandbox I posted. – Guilherme Oderdenge Sep 16 '22 at 02:55
  • Just to make my comment above clearer - most of the keys of my objects, in the real-world scenario I am facing, are numbers generated in runtime, so I don't really have control over the properties my objects have - their types, however, are explicitly defined during compile-time. – Guilherme Oderdenge Sep 16 '22 at 02:56
  • Hey @TobiasS. Sorry - ignore my two comments above. Your answer satisfied my needs pretty well. In short, I just replaced the path from `a.b.c` to `{x}.{y}.{z}` and it worked like a charm - I think I can skip the sugar API for now. So please, make it an answer and I'll accept it. Thank you so much! – Guilherme Oderdenge Sep 16 '22 at 03:04
  • How would your potential sugar API even work? Objects don't necessarily have an order to them. – kelsny Sep 16 '22 at 03:29

1 Answers1

1

Let's focus on trying to make prev type-safe first.

We want to take in a path P in the form of "a.b.c" and use this path to traverse the type of the object T to end up with the type of a.b.c. This can be "easily" achieved using template literal types.

type GetTypeAtPath<T, P extends string> = 
  P extends `${infer L}.${infer R}`
    ? GetTypeAtPath<T[L & keyof T], R>
    : P extends keyof T 
      ? T[P]
      : undefined

The type GetTypeAtPath first tries to split P into the two string literal types L and R. If that is possible, the type will call itself with T[L & keyof T] as the new object type and R as the new path.

If the string can not be split anymore because we reached the last element, we finally check if P is a key of T. If so, we can return T[K]. If not, we can just return undefined.

This will correctly type prev.

const ret1 = mutate(obj, "a.b.c", (prev) => "123")
//                                 ^? number

const ret2 = mutate(obj, "a.b.newProperty", (prev) => "123")
//                                           ^? undefined

But keep in mind that lodash's functions usually are a lot more versatile. They might also take paths like "a.b[0].c" or "a.b.0.c" to index arrays which this type currently not supports. There are also a million edge cases to consider. What if someone tries to pass a type with an index signature to the function like { a: Record<string, string | number> } or types like any?


As a bonus, I also worked out a return type for the function. I will not go into detail here and just outline what happens.

function mutate<
  T,
  P extends string, 
  N
>(
  obj: T, 
  path: P, 
  updater: (prev: GetTypeAtPath<T, P>) => N 
): ExpandRecursively<ChangeType<T, P, N> & ConstructType<P, N>> {
    return null!
}

We take the return type of updater to infer R. Now we want to take P, R and T and either change the type of T when the property already exists or add the path to T if it does not exist yet which will be done by ChangeType and ConstructType respectively.

type ChangeType<T, P extends string, N> = 
  {
    [K in keyof T]: P extends `${infer L}.${infer R}`
      ? L extends K 
        ? ChangeType<T[K], R, N>
        : T[K]
      : P extends K
        ? N
        : T[K]
  }

type ConstructType<P extends string, N> = 
  P extends `${infer L}.${infer R}`
    ? {
      [K in L]: ConstructType<R, N>
    } 
    : {
      [K in P]: N
    }

We simply intersect both results for the return type.


Playground

Tobias S.
  • 21,159
  • 4
  • 27
  • 45
  • 1
    A word of caution: the definition of `ExpandRecursively` will break on types like `Map`, `Set`, `Date`, etc, but it's not too hard to add in cases to prevent this. – kelsny Sep 16 '22 at 03:27