Template tags are just functions

A few years ago, ES6 introduced template literals, allowing among other things for multi-line strings, embedded expressions, and string interpolation.

That means that the following snippets of code could be written as follow:

console.log("This is the first line of a multi-line string.\n"
+ "And this is the second line!");

console.log(`This is the first line of a multi-line string.
And this is the second line!`);
const a = 22;
const b = 20;

console.log("The answer to the ultimate question of life, the universe, and everything is " + (a + b) + "!");

console.log(`The answer to the ultimate question of life, the universe, and everything is ${a + b}!`);

Template literals are already fairly useful with the above syntactic features, but there is more: template literals can be tagged!


Template tags are (mostly) functions that take in an array of strings as their first argument, and all expressions as the following arguments. Tags can then parse template literals as they see fit and return whichever value they see fit (not limited to strings).

const name1 = "Alice";
const name2 = "Bob";

function myTag (strings, fromName, toName) { 
  console.log(strings); // ["Template literal message from", " to ", " ..."]
  console.log(fromName); // "Alice"
  console.log(toName); // "Bob"

  ... 
}

console.log(myTag`Template literal message from ${name1} to ${name2} ...`);

If no tags are provided to the template literal, the default tag simply concatenates the strings and expressions into a single string, for example:

function defaultTag(strings, ...expressions) {
  let str = "";
  for (let i = 0, l = strings.length; i < l; i++) {
    str += strings[i] + (expressions[i] != null ? expressions[i] : "");
  }
  return str;
}


const name1 = "Alice";
const name2 = "Bob";
const a = 22;
const b = 20;

console.log(defaultTag`Template literal message from ${name1} to ${name2}: 'The answer to the ultimate question of life, the universe, and everything is ${a + b}!'`);

// "Template literal message from Alice to Bob: 'The answer to the ultimate question of life, the universe, and everything is 42}!'"
Note that there will always be more strings than expressions as empty strings will be added around expressions.

Now, we can probably build something a bit more interesting than just the default tag being applied to templates without tags!

Let's build a template tag that would allow us to format currency and numbers in certain ways. To better understand what we will build, let's look at an example:

const name = "Alice";
const number = 42;
const price = 20;

console.log(fmt`${name}:s has ${number}:n(1) oranges worth ${price}:c(USD)!`);
// "Alice has 42.0 oranges worth US$20.00!"
This particular example is based on this article by Jack Hsu about building an internationalization library with template tags. Definitely worth a read!
https://jaysoo.ca/2014/03/20/i18n-with-es2015-template-literals/

Here, we specify that the value interpolated by ${name} should be treated as a string, the value interpolated by ${number} should be displayed as a number with one digit, and that the value interpolated by ${price} should be displayed with the USD currency, all that while respecting the user's locale.

First, we need to define a way to extract the formatting information from the string literals:

const fmtRegex = /^:([a-z])(\((.+)\))?/;

function extractFormatOption(literal) {
  let format = "s";
  let option = null;

  const match = fmtRegex.exec(literal);
  if (match) {
    if (Object.keys(formatters).includes(match[1])) {
      format = match[1];
    }

    option = match[3];
  }

  return { format, option };
}

As an aside, every time I'm using regular expressions I am reminded of the following quote:

Some people, when confronted with a problem, think "I know, I'll use regular expressions." Now they have two problems. - Jamie Zawinski

But anyway, here we use a regular expression to match strings with our previously defined format, starting with : then a lower case letter, then an optional extra information in parenthesis.

The extractFormatOption() function simply helps us return the value of format and whatever option might have been passed as well. For example:

const { format, option } = extractFormatOption(`:c(USD)!`)
// format = "c"
// option = "USD"

Next, we need a way to actually format those values. We will use an object whose fields correspond to the potential values of format.

const formatters = {
  c(str, currency) {
    return Number(str).toLocaleString(undefined, {
      style: "currency",
      currency,
    });
  },
  n(str, digits) {
    return Number(str).toLocaleString(undefined, {
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    });
  },
  s(str) {
    return str != null ? str.toLocaleString() : "";
  },
};

Finally, we update our defaultTag() function to support extra formatting:

function fmt(strings, ...expressions) {
  let str = "";
  for (let i = 0, l = strings.length; i < l; i++) {
    str += strings[i].replace(fmtRegex, "");

    const { format, option } = extractFormatOption(
      i + 1 < l ? strings[i + 1] : ""
    );

    str += formatters[format](expressions[i], option);
  }
  return str;
}

Here we do a look-ahead and extract any format and option indications in the template literal (which default to "s"), then apply the corresponding formatter to the current expression we are interpolating.


As I found that exercise actually quite useful, I've published an npm package with more formatting options:

https://github.com/AntonioVdlC/fmt-tag

Antonio Villagra De La Cruz

Antonio Villagra De La Cruz

Multicultural software engineer passionate about building products that empower people.