Search code examples
javascriptnode.jsconfigjsdocdeep-copy

Preserve key/values for autocomplete after deep merge in JavaScript


I'm writing a rather minimalistic config system. The idea is having a config.template.js and a config.custom.js. Now, all set values from custom should override those in template. Values missing from custom will be read from template.

The logic for that looks something like this:

const isObject = item => item && typeof item === "object" && !Array.isArray(item);

const deepMerge = function(target, source){
    if (isObject(target) && isObject(source)){
        for (const key in source){
            if (isObject(source[key])){
                if (!target[key]) target[key] = {};
                deepMerge(target[key], source[key]);
            }
            else target[key] = source[key];
        }
    }
    return target;
};

// ...

const configCustom = (await import("./config.custom.js")).default;
const configBase = (await import("./config.template.js")).default;

export const config = {
    ...deepMerge(configBase, configCustom),
};

Now my problem:

VSCode doesn't know anything about how the resulting config actually looks like. So no autocomplete or types for keys.

VSCode would be able to provide autocomplete if I'd just do:

export const config = {
    ...configBase,
    ...configCustom,
};

This however results in a shallow copy for nested keys, effectively overriding entire objects/arrays with, well, nothing.

Since I already make heavy use of JSDoc, I thought I could annotate the deepMerge function like

/**
 * @param {object} target
 * @param {object} source
 * @return {import("./config.template.js").default}
 */

but that, of course, was wishful thinking and does not work.

So my question:

How can I provide autocomplete / types for this config system without relying on shallow copies?


I know that there are a ton of config systems and this is kinda re-inventing the wheel. I still want to understand and learn.
And yes, TypeScript would make this easier.

UPDATE:

@creepsore's answer worked perfectly well. However I had to change a couple of annotations because I got some overloading errors
(raised by VSCode with "js/ts.implicitProjectConfig.checkJs": true):

/**
 * @template {object} T
 * @template {object} T2
 * @param {T} target
 * @param {T2 & Partial<T>} source
 * @returns {T & T2}
 */
const deepMerge = function(target, source){
    if (isObject(target) && isObject(source)){
        for (const key in source){
            if (isObject(source[key])){
                if (!target[key]) target[key] = {};
                deepMerge(target[key], source[key]);
            }
            else target[key] = source[key];
        }
    }
    return /** @type {T & T2} */ (target);
};

// ...

export const config = {
    ...deepMerge(
        configBase,
        /** @type {Partial<typeof configBase>} */ (configCustom),
    ),
};

Probably not the cleanest approach, but works perfectly well!


Solution

  • It works when you define the two parameters of deepMerge as generics and use these as it's return type as such:

    const isObject = item => item && typeof item === "object" && !Array.isArray(item);
    
    /**
     * @template T
     * @template T2
     * @param {T} target 
     * @param {T2} source 
     * @returns {T&T2}
     */
    const deepMerge = function(target, source){
        if (isObject(target) && isObject(source)){
            for (const key in source){
                if (isObject(source[key])){
                    if (!target[key]) target[key] = {};
                    deepMerge(target[key], source[key]);
                }
                else target[key] = source[key];
            }
        }
        return target;
    };
    
    const configCustom = (await import("./config.custom.js")).default;
    const configBase = (await import("./config.template.js")).default;
    
    export const config = {
        ...deepMerge(configBase, configCustom),
    };
    

    Example

    These are my example configs i used for testing:

    // config.template.js
    export default {
        a: 420,
        b: 1337,
        c: 360
    };
    
    // config.custom.js
    export default {
        b: 420
    };