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),
};
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.
How can I provide autocomplete / types for this config system without relying on shallow copies?
@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!
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),
};
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
};