1

I have a merge function which merges objects at a specific path.

const merge = (src, path, newObj) => {
   // this code works fine
}

To use this function I call it this way:

interface IUser {
   user: {
      address: {
        street: string
      }
   }
}

interface IAddrNum {
   door: number
}

const User: IUser = {
   user: {
     address: {
        street: "New Street"
     }
   }
}


const mergeObj: IAddrNum = {
   door: 59
}

const newObj = merge(User, "user.address", mergeObj);

With that, I get the right result.

{
   user: {
     address: {
       street: "New Street"
       door: 59
     }
   }
}

Question: I want to create a signature for this function in typescript.

interface Immutable {
   merge<T, K, M>(
     src: T,
     path: K,
     merge: M
   ): T & M // <== this is where the problem is
}

This does not work as expected. It cannot be T & K because the merge has to happen at the specific path. Is it possible to have a signature of this function ? If so, can you give me some direction. Thanks.

Abhishek Saha
  • 2,564
  • 1
  • 19
  • 29
  • 1
    You can't do it with `"user.address"` since the compiler cannot parse strings at the type level. Instead you would need to use something like tuples of the form `["user", "address"] as const` to even begin to write this. Or you could make the `merge` parameter its own object like `{user: { address: {door: 59}}}` and not mention the path at all (in which case the return type really would look somewhat like `T & M`). I'd be happy to advise on either of those options, but dotted path string literals are a non-starter. – jcalz Mar 03 '20 at 19:41
  • Thanks @jcalz. I wasnt sure if the `"user.address"` would work. Thanks for explaining. I can make it `["user","address"]`. It would be great if you can guide me with this new change. – Abhishek Saha Mar 03 '20 at 20:16

1 Answers1

1

If you're okay with using a path tuple, then we need to manipulate tuples. A useful type alias is Tail<T> which takes a tuple type like [string, number, boolean] and returns another tuple with the first element removed, like [number, boolean]:

type Tail<T extends any[]> = 
  ((...t: T) => void) extends ((h: any, ...r: infer R) => void) ? R : never;

Then we can write DeepRecord<K, V> where K is a path of property keys, and V is some value. We make a recursive mapped conditional type that produces an object type where V is located down into the path described by K:

type DeepRecord<K extends PropertyKey[], V> =
    K extends [] ? V : { [P in K[0]]: DeepRecord<Tail<K>, V> };

Just to make sure that works, here's an example"

type Example = DeepRecord<["foo", "bar", "baz"], string>;
/* type Example = { foo: { bar: { baz: string; }; };} */

Now we're essentially done, but it's also nice to make something that merges intersections recursively so that instead of {foo: {bar: {baz: string}}} & {foo: {bar: {qux: number}}} you get a single {foo: {bar: {baz: string; qux: number;}}}:

type MergeIntersection<T> = 
  T extends object ? { [K in keyof T]: MergeIntersection<T[K]> } : T;

Finally, we can give merge() a type signature (and an implementation although that's out of the scope of the question and not guaranteed to be correct):

const merge = <T extends object, K extends N[] | [], 
  V extends object, N extends PropertyKey>(
    src: T, path: K, newObj: V
) => {
    const ret = { ...src } as MergeIntersection<T & DeepRecord<K, V>>;
    let obj: any = ret;
    for (let k of path) {
        if (!(k in obj)) {
            obj[k] = {};
        }
        obj = obj[k];
    }
    Object.assign(obj, newObj);
    return ret;
}

Note that N doesn't really do much in the definition, but it helps allow the compiler to infer that the path parameter constains literals and not just string. And the | [] in the constraint for K helps the compiler infer a tuple instead of an array. You want ["user", "address"] to be inferred as the type ["user", "address"] instead of as string[] or the whole thing falls apart. This annoying magic is the topic of microsoft/TypeScript#30680 and for now it's the best I can do.

You can test it on your example code:

const newObj = merge(User, ["user", "address"], mergeObj);
/* const newObj: {
    user: {
        address: {
            street: string;
            door: number;
        };
    };
}*/

console.log(JSON.stringify(newObj));
// {"user":{"address":{"street":"New Street","door":59}}}

Looks good I think. Okay, hope that helps; good luck!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you @jcalz for the nice explanation and breaking it down for me to understand. This worked very well. – Abhishek Saha Mar 03 '20 at 23:34
  • based on your code, I am trying to write a type for `del`. In this case it would be `del(User, ["user","address","street"])`. Could you please guide me on this ? I have modifield the `DeepRecord` to create the object but I am not able to subtract this from the source object. – Abhishek Saha Mar 04 '20 at 02:19
  • That's a different question, isn't it? I'd be happy to look at it but not in the comments section (and you might want to post a new question anyway to get other eyes on it in case I can't get to it) – jcalz Mar 04 '20 at 13:49
  • Sure, here is the link I just posted - https://stackoverflow.com/questions/60529460/types-for-deleting-object-at-specific-nested-path – Abhishek Saha Mar 04 '20 at 15:29