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.

2
  • 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. Commented Mar 3, 2020 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. Commented Mar 3, 2020 at 20:16

1 Answer 1

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

Sign up to request clarification or add additional context in comments.

4 Comments

Thank you @jcalz for the nice explanation and breaking it down for me to understand. This worked very well.
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.
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)
Sure, here is the link I just posted - stackoverflow.com/questions/60529460/…

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.