Once again, I find myself on a quest to write a piece of code that's been repeated since time immemorial. This time, I want to solve the timeless problem of taking a string with some formatting markers and filling it with some data. This should have been a no-brainer, but I think I have come up with a novelty, which I want to share with you.

I believe this is very simple stuff. However, I haven't seen anyone write the piece of code I want to show you today. Let's take a look at the problem, the most common solution I have found and what I came up with after some tinkering.

Also, I'm going to show you TypeScript code. You can remove the type annotations to translate it to plain JavaScript.

First of all, why?

You most likely know that JavaScript already has two ways of formatting a string: concatenation and with back-ticked format strings:

const myName = "Taxo";
const myAge = 28;

// Both produce "Hi! My name is Taxo and I'm 28 years old"
const str1 = "Hi! My name is " + myName + " and I'm " + myAge + " years old";
const str2 = `Hi! My name is ${myName}  and I'm ${myAge} years old`;

No tricks here! This technique (preferably the second one) is the one you'll be reaching for most of the time. If you write enough code, you'll find find there's a case when you cannot use it: when your format string is not an inline string literal.

Unless you declare the string and concatenate the values all at once, this expression is useless. The main way you'll find yourself in trouble is if you accept the format string as an argument or, as it is my case, you read it from HTML. Why would anyone do that? Because they inherited a codebase, of course!

So, we have a piece of code that wants to format an arbitrary string with an arbitrary set of data. For our sanity, we always know that the data set will match the string's format, otherwise this would be impossible. How do we solve this in a way that's simple, elegant and ergonomic?

What everyone seems to be doing

Every page I read that discusses string formatting offers a variation of the following:

interface String {
    format(...args: any[]): string;
}

String.prototype.format = function(...args: any[]) {
    return this.replace(/{(\d+)}/g, (match, number) =>
        args[number] !== undefined ? args[number] : match);
};

console.log("Hi! My name is {0} and I'm {1} years old".format("Taxo"));
// Hi! My name is Taxo and I'm {1} years old

console.log("Hi! My name is {0} and I'm {1} years old".format("Taxo", 28));
// Hi! My name is Taxo and I'm 28 years old

This is a pretty straightforward implementation of a string formatter: for every substring formed by a {, some numbers and a }, replace it with the argument positioned at that number. For example, {0} is replaced by the argument at position 0 ("Taxo") while {1} is replaced by the second argument, 28.

This implementation is good enough for most cases, but there are a couple of things I don't like:

  • It adds a function to String.prototype.
  • It binds the format to the position of the arguments it receives.

The first one is just me being nitpicky, but I don't feel comfortable extending prototype objects. Fortunately, we can solve this with a quick refactor:

function fmtPositional(str: string, ...args: any[]) {
    return str.replace(/{(\d+)}/g, (match, number) =>
        args[number] !== undefined ? args[number] : match);
};

I could pick on this code with stuff like "what if I want to display literal {0}?", but I could also do that with my solution, so we won't go down that rabbit hole. Also, leaving the format marker if we don't have enough arguments is perfectly fine. Let's assume that we're free to modify this code for our particular use cases.

Now, the second problem is the real deal breaker for my use case. I work with a library that takes an HTML document, parses some elements, matches it with JSON data and builds more HTML from both. In particular, for some of the data it builds, the HTML has some format placeholders:

<input label="interface_${iface}_address"

Since I want to fill a set of arbitrary strings with arbitrary data, I cannot meticulously build a list of arguments to the formatting function for every case. Instead, I need a general solution that will work with any set of parameters... That or call str.replace("${iface}", iface) every time. Wait, isn't that what my inherited code is already doing?

My formatter

First, let's declare the data we'll use to format the strings:

const DATA = { name: "Taxo", age: 28 };

My first approach to the formatting was the following:

function fmt1 (str: string, data: Record<string, any>) {
    str.matchAll(/\${([^}]+)}/g).forEach(match =>
        str = str.replace(match[0], data[match[1]]));
    return str;
}

// "Hi! My name is Taxo and I'm 28 years old"
fmt1("Hi! My name is ${name} and I'm ${age} years old", DATA)

This regex is a bit more complex, but it basically says "find a substring ${ followed by a sequence of characters that are not } and a closing }, then also return what's between { and }. Those are our formatting markers, which we access through match. This variable has two numbered fields:

  • match[0] is the whole matching string (e.g. ${name}).
  • match[1] is the inner group between ( and ) (e.g. name).

For every marker we find, we replace it with the corresponding value in the data table. This works for my use case, but it has a couple of issues we can sand (remember to test more than the happy path). Let's add an inner console.log real quick:

function fmt1 (str: string, data: Record<string, any>) {
    str.matchAll(/\${([^}]+)}/g).forEach(match => {
        console.log(`Formatting ${data[match[1]]}`);
        str = str.replace(match[0], data[match[1]]);
    });
    return str;
}

[
    "My name is ${name}, yes, ${name}, and I'm ${age} years old",
    "My name is ${name} and my value is ${number}",
].forEach(str => console.log(fmt1(str, DATA));

// Formatting Taxo
// Formatting Taxo
// Formatting 28
// My name is Taxo, yes, Taxo, and I'm 28 years old

// Formatting Taxo
// Formatting undefined
// My name is Taxo and my value is undefined

Oh. It turns out that iterating over the format markers is slower if we reference the same variable twice. Also, that undefined when we cannot find a value is not very helpful. We could fix it by adding a type check to keep the format marker if the matched data field is undefined, but we can also solve both issues by iterating over the data object instead:

function fmt2 (str: string, data: Record<string, any>) {
    Object.keys(data).forEach(key =>
        str = str.replaceAll(`\${${key}}`, data[key]));
    return str;
}

First, let's pat ourselves in the back for removing the regex! Now we just iterate over the keys in data and replace all the format markers that match the key, so we don't iterate over the markers twice. Note that the query string for replaceAll escapes the first $ so the variable substitution is done at the inner ${key}. This also solves the undefined problem by leaving any markers the function couldn't substitute, which will help our debugging later on.

As for efficiency, this should (remember to benchmark) be a bit faster, since replaceAll will replace all substrings matching the current format marker in one call. That way, we use language-level assignment once per marker type instead of once per individual marker. This should hopefully speed things up a bit, as we're avoiding some unnecessary allocations by limiting the number of assignments we run inside the function.

Finally, there's one quick thing we can refactor to make this perfect. In every iteration we're updating str and we're returning it at the end. Whenever we see this pattern, we can apply a reduction instead of a loop with an assignment:

function fmt (str: string, data: Record<string, any>) {
    return Object.keys(data).reduce(
        (str, key) => str.replaceAll(`\${${key}}`, data[key]),
        str);
}

Conclusion

There we have it! We can now use this simple fmt function to format arbitrary strings with data we gather from a plain object acting as a dictionary. But wait, you'll say, this is not the one-liner I was promised in the title! True, but it's a single expression that you can compress in an arrow function:

let f=(s,d)=>Object.keys(d).reduce((s,k)=>s.replaceAll(`\${${k}}`,d[k]),s);

Not that you should!

Note that this technique is not unique to JavaScript. We should be able to easily translate this function for any language with good support for tables. For example, this is how we can do the same in Lua (where we don't have forEach or reduce):

local function fmt (str, data)
    for k, v in pairs(data) do
        str = str:gsub("${"..k.."}", v)
    end
    return str
end

local data = { name = "Taxo", age = 28 }

for _, str in ipairs({
        "My name is ${name}, and I'm ${age} years old",
        "My name is ${name}, yes, ${name}, and I'm ${age} years old",
        "My name is ${name} and my value is ${number}"}) do
    print(fmt(str, data))
end

Thank you for taking the time to read this! I hope you found this useful or, at least, entertaining!