I ran into the same issue, so I’ve implemented a string-extension method to handle this requirement. I’ve also covered most edge cases in my tests, so you can use it confidently.
Utility functions
const isNull = obj => obj === null;
const isUndefined = obj => typeof obj === 'undefined';
const isNullOrUndefined = obj => isUndefined(obj) || isNull(obj);
const isObject = obj =>
!isNullOrUndefined(obj) &&
typeof obj === 'object' &&
!Array.isArray(obj);
function stringify(data) {
switch (typeof data) {
case 'undefined':
return 'undefined';
case 'boolean':
return data ? 'true' : 'false';
case 'number':
return String(data);
case 'string':
return data;
case 'symbol':
case 'function':
return data.toString();
case 'object':
if (isNull(data)) {
return 'null';
}
if (data instanceof Error) {
return data.toString();
}
if (data instanceof Date) {
return data.toISOString();
}
return JSON.stringify(data, null, 2);
default:
return 'unknown';
}
}
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function hasFunction(obj, method) {
return (
isObject(obj) &&
method in obj &&
typeof obj[method] === 'function'
);
}
replaceVariable extension
String.prototype.replaceVariable = function (replacements, prefix = '%#', suffix = '#%') {
let current = this.toString();
const seen = new Set();
// Escape prefix/suffix in case they contain regex metacharacters
prefix = escapeRegex(prefix);
suffix = escapeRegex(suffix);
// Build regex patterns for each placeholder
const patterns = Object.keys(replacements).map(key => {
const escKey = escapeRegex(key);
return {
value: replacements[key],
placeholderRegex: new RegExp(`${prefix}${escKey}(?=(?::.*?${suffix}|${suffix}))(?::.*?)?${suffix}`, 'gs'),
formatRegex: new RegExp(`(?<=${prefix}${escKey}(?=(?::.*?${suffix}|${suffix})):).*?(?=${suffix})`, 'gs')
};
});
// Repeat until no more replacements occur or we detect a loop
while (true) {
if (seen.has(current)) break;
seen.add(current);
let next = current;
for (const {value, placeholderRegex, placeholderFormatRegex} of patterns) {
if (placeholderRegex.test(next)) {
let format = next.match(placeholderFormatRegex)
if (!isNullOrUndefined(format) && format.length != 0 && hasFunction(value, 'format')) {
next = next.replace(placeholderRegex, stringify(value.format(format[0])));
} else {
next = next.replace(placeholderRegex, stringify(value));
}
}
}
if (current === next) break;
current = next;
}
return current;
};
Example format method for Date
Date.prototype.format = function (fmt) {
const pad = num => num.toString().padStart(2, '0');
const map = {
YYYY: this.getFullYear(),
MM: pad(this.getMonth() + 1),
DD: pad(this.getDate()),
HH: pad(this.getHours()),
mm: pad(this.getMinutes()),
ss: pad(this.getSeconds()),
};
return fmt.replace(/YYYY|MM|DD|HH|mm|ss/g, key => map[key]);
};
Usage example
console.log('Hello {{name}}! {{time:(YYYY-MM-DD HH:mm:ss)}}'.replaceVariable({
name: 'World',
time: new Date('2025-05-09T12:28:12'),
}, '{{', '}}'));
// → Hello World! (2025-05-09 12:28:12)