Lennard Fi
Lennard Fi

Reputation: 17

TypeScript definition to merge objects, but prevent the merging of different types in the object properties

I want to write a function that extends a object with another object. For example: We will call one object src and the other ext. The function has to return (a copy) of the src object but deeply (recursively) extends the object with the ext object. If the data type of one (sub-) property of the ext doesn't matches the type of the src (sub-) property the function has to ignore the ext value. New properties from ext (not existing in src) will be added to the result object.

For better understanding here an complete example:

/** A helper definition to build indexable objects */
interface indexObject<T> {
    [key: string]: T
}

type SafelyMergedObject<Src extends indexObject<any>, Ext extends indexObject<any>> = {
    // The searched type definition
}

const src= {
    a: "a",
    b: "b",
    c: false,
    d: {
        a: "Alice",
        b: "Bob",
    }
}
const ext = {
    a: ["a"],
    b: 1,
    c: true,
    d: {
        a: "Ann",
        c: "Clair",
    },
    e: "New value",
}
const result: SafelyMergedObject<typeof src, typeof ext> = {
    a: "a", /** Only string should be allowed here because
                it's the property type of the source (Src) type */
    b: "b", /** Same as above (`a` property) */
    c: true,/** Since `c` has the same data type the function
                should return the property value of the `ext`
                object */
    d: {    /** Same data type in both objects */
        a: "Ann",   /** new value from `ext` */
        b: "Bob",   /** copied value from `src` */
        c: "Clair", /** new property from `ext` */
    },
    e: "New Value", /** new property from `ext` */
}

TypeScript Playground Link

I know how to write the function. That is easy but I don't know how to write this type definition. Is that even possible?

The default TypeScript type inference behavior doesn't fit my problem because the Types are recursive and more complex than the simple object type. I will use the function for example to load user specific configurations into my app. The user configuration could be corrupted. So I have to merge the default configuration with the user specific configuration.

Upvotes: 1

Views: 1580

Answers (1)

jcalz
jcalz

Reputation: 327934

I will interpret what you're asking for as the following: for object types T and U, the object type SafelyMergedObject<T, U> should have the same keys as T & U, but with some differences to the property types. If a key K exists in just T or U but not both, then use the property as-is (so this is the same as T & U). If the key K exists in both T and U and at least one of the two property types is not an object, then use the property type from T and ignore the property from U. If the key K exists in both T and U and is an object type in both T and U, then recurse down into that property via SafelyMergedObject<T[K], U[K]>.

This gets translated into something like:

type SafelyMergedObject<T, U> = (
    Omit<U, keyof T> & { [K in keyof T]:
        K extends keyof U ? (
            [U[K], T[K]] extends [object, object] ?
            SafelyMergedObject<T[K], U[K]>
            : T[K]
        ) : T[K] }
) extends infer O ? { [K in keyof O]: O[K] } : never;

Here we are first outputting Omit<U, keyof T>, which is the properties from U that do not exist in T. Then we are going through the keys of T, and outputting T[K] if the property is not in U, or if it is in U but at least one of T[K] or U[K] is not an object.

The only "trick" here is extends infer O ? {[K in keyof O]: O[K]} : never. All this does is "prettify" or "expand" the object type by iterating over all the keys and merging the result into a single object type.

Let's see it in action with your src and ext values:

type Result = SafelyMergedObject<typeof src, typeof ext>;

If you hover over that with IntelliSense you'll see:

type Result = {
    e: string;
    a: string;
    b: string;
    c: boolean;
    d: {
        c: string;
        a: string;
        b: string;
    };
}

which is, I think, what you wanted. Note that if I didn't include the extends infer O... line, the Result type would be evaluated as:

type Result = Pick<{
    a: string[];
    b: number;
    c: boolean;
    d: {
        a: string;
        c: string;
    };
    e: string;
}, "e"> & {
    a: string;
    b: string;
    c: boolean;
    d: SafelyMergedObject<{
        a: string;
        b: string;
    }, {
        a: string;
        c: string;
    }>;
}

which is significantly harder to understand, although it amounts to the same type.


Note that there are probably all sorts of edge cases that will crop up if you use the above SafelyMergedObject<T, U> in different situations. You'll need to decide what you want the output to look like in those situations and possibly tweak the definition to get those to happen. So be careful.

Okay, hope that helps; good luck!

Playground link to code

Upvotes: 3

Related Questions