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
"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 constto even begin to write this. Or you could make themergeparameter 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 likeT & M). I'd be happy to advise on either of those options, but dotted path string literals are a non-starter."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.