Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to properly type a dynamic recursive function that merges multiple objects together?

I'm trying to type the below function, which works as expected, but is not typed correctly. I'd like it to autosuggest available values for me as I type them.

I'm relatively new to Typescript, but I haven't been able to figure it out. Here's what I have:

const endpointsA = (base: string) => ({
    GET: {
        FEATURE_A: {
            SOME_PATH: `${base}/featureA`,
        },
    },
});

const endpointsB = (base: string) => ({
    GET: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB`,
        },
    },
    POST: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB`,
        },
    },
});

type TMergeEndpoints = {
    [key: string]: Record<string, unknown>;
}[];
type TRecursiveMerge = {
    obj: {
        [key: string]: Record<string, unknown>;
    };
    entries: Array<[string, Record<string, unknown>]>;
};
const mergeEndpoints = (endpoints: TMergeEndpoints) => {
    const result = {};

    const recursiveMerge = ({ obj, entries }: TRecursiveMerge) => {
        for (const [key, value] of entries) {
            if (typeof value === 'object') {
                obj[key] = obj[key] ? { ...obj[key] } : {};
                recursiveMerge({
                    obj: obj[key] as TRecursiveMerge['obj'],
                    entries: Object.entries(value) as TRecursiveMerge['entries'],
                });
            } else {
                obj[key] = value;
            }
        }

        return obj;
    };

    endpoints.forEach((endpoint) => {
        recursiveMerge({ obj: result, entries: Object.entries(endpoint) });
    });

    return result;
};

const base = '/api';

const endpoints = mergeEndpoints([
    endpointsA(base),
    endpointsB(base),
]);

console.log('endpoints', endpoints);

This correctly merges my object, however I get no type suggestion. I've tried playing around with <T>, placing it here and there, but that didn't work out too well. What can I do here?

Typescript playground

Edit

Ended up using Michael's suggestion and got something like this:

/**
    Each type has a definition for each entry that it has.

    For example, 

    const a = (base: string): A => ({
        GET: {
            FEATURE_A: {
                 SOME_PATH: `${base}/some-path`
            }
        }
    });

    type A = {
        GET: {
            FEATURE_A: {
                 SOME_PATH: string
            }
        }
    }
*/
type TEndpoints = A & B & C;

const endpoints = mergeEndpoints<TEndpoints>([a(base), b(base), c(base)]);

/**
    `mergeEndpoints` stayed the same with one exception (I pass T into it during runtime)
*/

    type TMergeEndpoints = {
        [key: string]: Record<string, unknown>;
    }[];
    type TRecursiveMerge = {
        obj: {
            [key: string]: Record<string, unknown>;
        };
        entries: Array<[string, Record<string, unknown>]>;
    };
    export const mergeEndpoints = <T>(endpoints: TMergeEndpoints): T => {
        const result = {};

        const recursiveMerge = ({ obj, entries }: TRecursiveMerge) => {
            for (const [key, value] of entries) {
                if (typeof value === 'object') {
                    obj[key] = obj[key] ? { ...obj[key] } : {};
                    recursiveMerge({
                        obj: obj[key] as TRecursiveMerge['obj'],
                        entries: Object.entries(value) as TRecursiveMerge['entries'],
                    });
                } else {
                    obj[key] = value;
                }
            }

            return obj;
        };

        endpoints.forEach((endpoint) => {
            recursiveMerge({ obj: result, entries: Object.entries(endpoint) });
        });

        return result as T;
    };

Playground link

like image 919
Mike K Avatar asked Mar 30 '21 14:03

Mike K


People also ask

How do you write a recursive function?

Writing a recursive function is almost the same as reading one: Create a regular function with a base case that can be reached with its parameters. Pass arguments into the function that immediately trigger the base case. Pass the next arguments that trigger the recursive call just once.

What are the 3 parts that make a function recursive?

Parts of a Recursive AlgorithmBase Case (i.e., when to stop) Work toward Base Case. Recursive Call (i.e., call ourselves)

Can a recursive function have multiple base cases?

A recursive implementation may have more than one base case, or more than one recursive step. For example, the Fibonacci function has two base cases, n=0 and n=1.

How do you write a recursive function in JavaScript?

Recursion is a process of calling itself. A function that calls itself is called a recursive function. The syntax for recursive function is: function recurse() { // function code recurse(); // function code } recurse();


2 Answers

In my answer I'm going to concentrate on your immediate question without trying to impart a different approach or change what you are doing too much. This will require some explanations.

Type inference

First of all, it's good to remember that TypeScript has a type for everything; if a type is not given, it is inferred. This means, that after you do const result = {};, it has an empty object for a type. When you return it, result is going to be of that type regardless of what you do to the object before that.

Secondly, if you use "strict": true in your tsconfig.json (which is most useful), you are not going to be allowed to reference anything inside the returned object before you type cast it in any way. This again, is because the type is {}. It's not just code suggestions in your IDE!

The inferred types

The type of your mergeEndpoints function is actually (endpoints: TMergeEndpoints) => {} where {} is literally the type, it's not even object.

It's generally a good idea to annotate the return type of functions or methods unless they are one-liners: saves time when you modify and refactor your code by notifying you about the type actually returned not matching the declaration.

Also note, that endpointsA and endpointsB, as well as the objects returned by these functions, are all of different unrelated types. No matter how you choose to merge them, you can't get a concrete type without declaring it in any way.

Type casting

Lastly, when you use type casting with the as operator, you lose the type system help. You are basically saying: "don't check anything, I know which type it is". This defeats the whole point of having a sound type system, which is, of course, writing the code that is correct, safe, easy to understand and maintain with less time and effort.

TypeScript is all about making things obvious and explicit. It's on you to allow it to do its job. Ignoring inferred types, breaking type system logic by using things like as and !, not using strict checks, not using a linter––all this may result in just having "JavaScript with peculiar syntax", without the benefits.

Fixes

With the above in mind, let's do some changes to your code. Keep in mind, that I'm intentionally not trying to change the gist of what you are doing.

First, Record<string, unknown> is any object. There is no need to create another wrapper interface.

// Passing a list of whatever objects
const mergeEndpoints = (endpoints: Record<string, unknown>[]) => {

    // ... TODO

}

Second, let's decide what the merged type should be. Unless you want to declare an interface for endpoints, the most obvious candidate is again Record<string, unknown>. Note that the return type can be inferred without annotating it, but having the annotation in place makes thing more obvious and prevents from accidentally changing the return type: it's not going to compile.

// Annotating the return type
const mergeEndpoints = (endpoints: Record<string, unknown>[]): Record<string, unknown> => {
    const result: Record<string, unknown> = {};
    
    // ... TODO

    return result;
}

Now endpointsA(base) and endpointsB(base) are both assignable to the type Record<string, unknown>. This call will compile:

const endpoints = mergeEndpoints([
    endpointsA(base), // Type check passes, because Record<string, unknown> is any object
    endpointsB(base), 
]);

Let's modify the body of the merging function. Object.entries() returns an array of tuples [string, unknown]. We can use this type as is without wrapping it in another type. Also, what's the point of declaring a new type TRecursiveMerge just to destructure it immediately into separate fields? Let's get rid of it! (Another option would be to remove destructuring).

const mergeEndpoints = (endpoints: Record<string, unknown>[]): Record<string, unknown> => {
    const result: Record<string, unknown> = {};

    // There is nothing wrong in just passing two arguments
    const recursiveMerge = (obj: Record<string, unknown>, entries: [string, unknown][]) => {
        
        // ... TODO

    };

    endpoints.forEach((endpoint) => {
        // Passing target object and something to copy
        recursiveMerge(result, Object.entries(endpoint));
    });

    return result;
};

Now, there are two things remaining: first is that typeof null is object 🤷‍♂️ (hello JavaScript).

    const recursiveMerge = (obj: Record<string, unknown>, entries: [string, unknown][]) => {
        for (const [key, value] of entries) {
            if (typeof value === 'object' && value != null) {
                
                // ... TODO

            } else {
                obj[key] = value;
            }
        }
    };

Second, you are using a truthy value conversion when you use obj[key] in a ternary if. Type conversion is one of the JavaScript's eldritch horrors and it is better to use TypeScript's explicitness. What we want to do there is the same as we just did: check it's not null and is an object. To avoid code duplication we can use a type guard:

// When used in an if clause, this function lets the type system know
// the type of a given object using a custom check
const isNonNullObject = (o: unknown): o is Record<string, unknown> => {
    return typeof o === 'object' && o != null;
};

So the resulting function looks like this:

const mergeEndpoints = (endpoints: Record<string, unknown>[]): Record<string, unknown> => {
    const result: Record<string, unknown> = {};

    // A simple type guard to avoid duplicating the check
    const isNonNullObject = (o: unknown): o is Record<string, unknown> => {
        return typeof o === 'object' && o != null;
    };

    const recursiveMerge = (obj: Record<string, unknown>, entries: [string, unknown][]) => {
        for (const [key, value] of entries) {
            if (isNonNullObject(value)) {
                // Here the type system is aware that `value` is an object and is not null
                
                // Using the type guard again to check the existing value:
                // this makes the type system aware of the type of `existing`
                const existing = obj[key];
                const copy = isNonNullObject(existing) ? { ...existing } : {};

                recursiveMerge(copy, Object.entries(value));
                obj[key] = copy;
            } else {
                obj[key] = value;
            }
        }
    };

    endpoints.forEach((endpoint) => {
        recursiveMerge(result, Object.entries(endpoint));
    });

    return result;
};  

And finally, with everything in place your merged endpoints object becomes Record<string, unknown>. This doesn't seem to be very useful and you may consider creating interfaces/types/classes for entry points. Here is what it may look like:

interface Feature {
    SOME_PATH: string;
}
interface Endpoint {
    GET: {
        FEATURE_A?: Feature;
        FEATURE_B?: Feature;
    };
    POST: {
        FEATURE_A?: Feature;
        FEATURE_B?: Feature;
    };
}

Here is a playground of everything described here.

Conclusion

There are a lot of ways of doing all of this differently. The goal of this exercise was to show the TypeScript way of doing things while not diverging from the initial implementation too much.

like image 74
Michael Antipin Avatar answered Oct 22 '22 08:10

Michael Antipin


1

Let's split your problem into several smaller.

First of all, let's get rid all type castings, I mean as operator.

Here, you are using type casting:

obj: obj[key] as TRecursiveMerge['obj'],

because your type is not recursive. To make it recursive, you should use interface instead of type.

See example:


interface RecursiveRecord {
    [prop: string]: Record<string, RecursiveRecord>
}

type TRecursiveMerge = {
    obj: RecursiveRecord;
    entries: Array<[string, Record<string, unknown>]>;
};

Now, there is no error near obj: obj[key]

2

Try to use reduce instead of for .. of loop.

TypeScript does not play well if you are mutating your objects.

Like you did here: obj[key] =

3

Try to use more robust types for your objects:

type Feature<Name extends string, Base extends string> = {
    [P in Name as `feature_${Name}`]: {
        SOME_PATH: `${Base}/${P}`

    }
}
type Result = Feature<'A', '/api'>

4

If you have config object defined ahead, don't use recursion to merge it.

Split your function into several smaller.

Lets say you have next two objects:

    GET: {
        FEATURE_A: {
            SOME_PATH: `${base}/featureA`,
        },
    },

and

    GET: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB`,
        },
    },

5

Here is my proposition of solution:

    const a = {
        GET: {
            FEATURE_A: {
                SOME_PATH: '/featureA',
            },
        },
        POST: {
            FEATURE_A: {
                SOME_PATH: `${base}/featureA` as const,
            },
        },
    };

    const b = {
        GET: {
            FEATURE_B: {
                SOME_PATH: '/featureB',
            },
        },
        POST: {
            FEATURE_B: {
                SOME_PATH: `${base}/featureB` as const,
            },
        },
    };
    type A = typeof a;
    type B = typeof b;

    type CommonKeys = keyof (A | B);

    /**
     * Typesafe check if object has own property, no somewhere in the prototype chain
     */
    const hasProperty = <T, Prop extends string>(obj: T, prop: Prop): obj is T & Record<Prop, unknown> =>
        Object.prototype.hasOwnProperty.call(obj, prop)

    /**
     * Get common/sharable keys between two objects
     */
    const getCommonKeys = <Keys extends string>(a: Record<Keys, unknown>, b: Record<Keys, unknown>) =>
        // here, I'm using `filter` as a typeguard
        Object.keys(a).filter((key): key is CommonKeys => hasProperty(b, key));

    type Result = Record<CommonKeys, A[CommonKeys] & B[CommonKeys]>;

    const result = getCommonKeys(a, b).reduce((acc, elem) => ({
        ...acc,
        [elem]: {
            ...a[elem],
            ...b[elem]
        }

    }), {} as Result);

UPDATE

const base = '/api' as const

const a = {
    GET: {
        FEATURE_A: {
            SOME_PATH: '/featureA',
        },
    },
    POST: {
        FEATURE_A: {
            SOME_PATH: `${base}/featureA` as const,
        },
    },
}

const b = {
    GET: {
        FEATURE_B: {
            SOME_PATH: '/featureB',
        },
    },
    POST: {
        FEATURE_B: {
            SOME_PATH: `${base}/featureB` as const,
            SOME_PATH2: `${base}/featureB_2` as const,

        },
    },
}


const merge = <T, U>(a: T, b: U) => ({ ...a, ...b })

const result = merge(a, b).POST.FEATURE_B.SOME_PATH2 // ok
like image 1
captain-yossarian Avatar answered Oct 22 '22 09:10

captain-yossarian