Search code examples
typescriptlodash

How to go about a type-safe, lodash's _.update utility?


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.

Solution

  • 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