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