Formatting Strings in JavaScript with Template Tags

A few months ago, I published a blog article about template tags in JavaScript. As an example of interesting usage of template tags as an abstraction, I used Jack Hsu's article about using them to build an internationalization library as inspiration. I ended up pushing a similar idea forward and published an npm package that leveraged template tags to format strings in JavaScript: fmt-tag. In this article, we'll take a closer look at this package!


Why use template tags to format strings?

But first, let's take a look at the main motivations why template tags might be a useful abstraction to format strings.

Up until recently, the options to format numbers or dates in JavaScript have been fairly limited. I remember having to manually write functions to format a date in a specific way. On top of that, internationalization support was also lacking, and created even more complexity.

As an example of code we would write to support date display:

function formatDate(date, locale) {
  const year = date.getFullYear();
  const month = date.getMonth() + 1; // Because this starts counting from 0 (0 -> January)
  const day = date.getDate();

  switch (locale) {
    case "de-DE":
      return [day, month, year].join(".");
	case "en-US":
	  return [month, day, year].join("/");
    
    // and then a lot more of those cases
    // ...
  }
}


console.log(formatDate(new Date(), "de-DE")); // 27.3.2022
console.log(formatDate(new Date(), "en-US")); // 3/27/2022

Luckily, with the addition of the Internationalization API in all main browsers (including IE11, if that's still a thing), those days are hopefully past all of us. With the Internationalization API, we can simplify our formatDate function as follows:

function formatDate(date, locale) {
  return new Intl.DateTimeFormat(locale).format(date)
}

console.log(formatDate(new Date(), "de-DE")); // 27.3.2022
console.log(formatDate(new Date(), "en-US")); // 3/27/2022

And that function can now support all locales out of the box!

The Internationalization API provides powerful methods to format numbers and dates in all sort of relevant ways, with built-in support for all locales. We can for example, format dates like this:

console.log(new Intl.DateTimeFormat('en-US', {
  year: 'numeric', month: 'numeric', day: 'numeric',
  hour: 'numeric', minute: 'numeric', second: 'numeric',
  hour12: false,
  timeZone: 'America/Los_Angeles'
}).format(new Date()));

// 3/27/2022, 03:03:03

While that level of flexibility is fantastic, it might not be the right level of abstraction for all use cases. That is why, hiding some of that complexity with some sensible default values under template tags might make sense!

Ultimately, we'd like to arrive at the following API:

const now = new Date();
const name = "Alice";
const money = 20;

console.log(
  fmt`Today's date is: ${now}:d(DD-MM-YYYY). ${name}:s has ${money}:c(USD) in her pocket!`
);
// Today's date is: 27.3.2022. Alice has 20,00 $ in her pocket! (if locale is set to "de-DE")
// Today's date is: 3/27/2022. Alice has $20.00 in her pocket! (if locale is set to "en-US")
// ...

Implementation

Now that we have covered some the motivations behind our endeavor, let's get right into it!

Overall architecture

Let's take the previous code as a basis for designing our library and its components:

const name = "Alice";
const money = 20;

console.log(
  fmt`${name}:s has ${money}:c(USD) in her pocket!`
);
// Alice has 20,00 $ in her pocket! (if locale is set to "de-DE")
// Alice has $20.00 in her pocket! (if locale is set to "en-US")
// ...

So, the very first building block is to start with a template tag, which happen to just be a function:

function fmt(literals: TemplateStringsArray, ...substs: string[]): string {
  // Do something interesting!	
}

What we need to do when parsing the string in the fmt function is:

  • extract any formatting hints in the literals
  • use a formatter function to transform the interpolated value in accordance to the formatting hints
  • remove the formatting hints from the final string

For example, if we have the following code:

const money = 20;
const str = fmt`I have ${money}:c(USD).`;

The fmt function needs to identify the formatting hint :c(USD), apply it to ${money}, and remove it from the final string, resulting in: "I have $20.00.".

Extracting formatting hints

Let's focus first on extracting the formatting hints. If we look back at the fmt function, we can start filling in the implementation:

function fmt(literals: TemplateStringsArray, ...substs: string[]): string {
  let str = "";
  for (let i = 0, l = literals.length; i < l; i++) {
	// TODO: Do something a bit more interesting!
    str += literals[i] + substs[i];
  }
  return str;
}

We are now looping over the literals in the template strings, and simply returning the same string literal with the interpolations, so it's not that interesting per se!

All the hints have the same shape: : followed by a letter c and optionally some more hints in parenthesis (USD). We can use a regular expression to extract those values from the template string:

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

And we can use that regular expression to write a function that will return the format and options from a string literal:

function extractFormatOption(literal: string) {
  const match = fmtRegex.exec(literal);

  const format = match[1];
  const option = match[3];

  return { format, option };
}

As an example:

const literal = ":c(USD) in my pocket."
const { format, option } = extractFormatOption(literal);

console.log(format); // "c"
console.log(option); // "USD"

We can then call that function in fmt:

function fmt(literals: TemplateStringsArray, ...substs: string[]): string {
  let str = "";
  for (let i = 0, l = literals.length; i < l; i++) {
	str += literals[i];

    const { format, option } = extractFormatOption(
      i + 1 < l ? literals[i + 1] : ""
    );
	
	// TODO: Do something interesting!
	str += substs[i]
  }
  return str;
}

Calling the appropriate formatters

Now that we are able to extract formatting hints from template strings, it's time to actually format the interpolations using those hints! To do so, we will be defining formatters, which are functions that take a string and other arguments, and returns a string.

For example, we can have a currency formatter as follows:

function currencyFormatter(str: string, currency: string): string {
  return new Intl.NumberFormat(undefined, {
    style: "currency",
    currency,
  }).format(Number(str));
}

As a default formatter, we will also implement a string formatter, which just returns the interpolation as a string. This is useful so that people using the library won't need to add hints on every interpolation (making it backwards compatible), and as a default if the hint specified by a user isn't a registered formatter (for example using :g when no formatter has been register for that hint).

The string formatter is extremely simple:

function stringFormatter(str: string): string {
  return str != null ? str.toLocaleString() : "";
}

To facilitate the calls to formatters from the fmt function, we can wrap those formatters into a single object, where the keys would be the value of format returned by extractFormatOption, and the values would be the functions we defined previously.

All together, it looks like this:

const formatters: Record<string, Function> = {
  c(str: string, currency: string): string {
    return new Intl.NumberFormat(undefined, {
      style: "currency",
      currency,
    }).format(Number(str));
  },
  s(str: string): string {
    return str != null ? str.toLocaleString() : "";
  },
};

We can now call the specified formatter when interpolation a template string inside the fmt function:

function fmt(literals: TemplateStringsArray, ...substs: string[]): string {
  let str = "";
  for (let i = 0, l = literals.length; i < l; i++) {
    str += literals[i];

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

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

To make sure that our library can support some of the edge cases specified above (namely, having the string formatter as the default formatter), we need to update our extractFormatOption function implementation as follows:

function extractFormatOption(literal: string) {
  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 };
}

Remove formatting hints  from the final string

If we run our current implementation, we notice that there is still a piece missing to make it work! Indeed, the formatting hints will still be displayed in the resulting string:

const money = 20;
console.log(fmt`I have ${money}:c(USD) in my pocket.`);
// I have $20.00:c(USD) in my pocket.

Not great, but a fairly easy fix to apply! Here, we need to remove the formatting hints from the string literals:

function fmt(literals: TemplateStringsArray, ...substs: string[]): string {
  let str = "";
  for (let i = 0, l = literals.length; i < l; i++) {
    str += literals[i].replace(fmtRegex, "");
    ...
  }
  return str;
}

And that should do the trick!

Allowing for interpolation in the formatting hints

The above code works great for most cases, but what if the options passed to the formatting hints need to be dynamically calculated?

Currently, the following code would not work:

const name = "Alice";
const money = 20;
const country = "UK";

console.log(fmt`${name} has ${money}:c(${country === "UK" ? "GBP" : "USD"}) in her pocket!`);
// Uncaught TypeError: Currency code is required with currency style.

The reason that code throws an error is that the value of the :c() hint hasn't been interpolated when we apply the formatting to ${money}. We can either declare this a known limitation of our library and require users to write code like follows:

const name = "Alice";
const money = 20;
const country = "UK";

if (country === "UK") {
  console.log(fmt`${name} has ${money}:c("GBP") in her pocket!`);
  // 'Alice has £20.00 in her pocket!'
} else {
  console.log(fmt`${name} has ${money}:c("USD") in her pocket!`);
  // 'Alice has US$20.00 in her pocket!'
}

... or we can make some tweaks to the fmt template tag to support such interpolations in the formatting hint option:

function fmt(literals: TemplateStringsArray, ...substs: string[]): string {
  let str = "";

  // We iterate backwards to allow for dynamic hints (`option`) in formats,
  // for example currency depending on some `country` variable.
  for (let i = substs.length - 1; i >= 0; i--) {
    // We can always look up `i + 1` because there is always one more literal
    // than there are interpolations in a template literal
    str = literals[i + 1] + str;

    const { format, option } = extractFormatOption(
      // Here we need to pass the whole string to date because we need
      // the interpolations that have occured so far in case there are any
      // dynamic hints.
      str,
      fmtRegex,
      Object.keys(formatters)
    );

    // Don't forget to remove the format hints!
    // We do this here after extracting the format hint and option.
    str = formatters[format](substs[i], option) + str.replace(fmtRegex, "");
  }

  // After we are done with all the interpolatinos, prepend the first literal
  str = literals[0] + str;

  return str;
}

Default formatters

Now that we've look at the parts of the library that are doing the heavy lifting, adding default formatters is as simple as adding a new field in the formatters object!

Let's look at the implementation of the existing default formatters:

Currency

The currency formatter allows for a number to be formatted in a specified currency.

const formatters: Record<string, Function> = {
  c(str: string, currency: string): string {
    return new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
    }).format(Number(str));
  },
  ...
};

For example:

const name = "Alice";
const money = 20;

console.log(fmt`${name} has ${money}:c("GBP") in her pocket!`);
// 'Alice has £20.00 in her pocket!'

console.log(fmt`${name} has ${money}:c("USD") in her pocket!`);
// 'Alice has US$20.00 in her pocket!'

Date

The date formatter adds a layer of abstraction on to the the Intl.DateTimeFormat formatter from the Internationalization API. In particular, it only deals with the "date" part.

const formatters: Record<string, Function> = {
  ...
  d(str: string, format: string): string {
    let dateFormatter;
    switch (format) {
      case "ddd-mmm-YYYY":
        dateFormatter = new Intl.DateTimeFormat(locale, {
          dateStyle: "full",
        });
        break;
      case "DD-mmm-YYYY":
        dateFormatter = new Intl.DateTimeFormat(locale, {
          dateStyle: "long",
        });
        break;
      case "DD-mm-YYYY":
        dateFormatter = new Intl.DateTimeFormat(locale, {
          dateStyle: "medium",
        });
        break;
      case "DD-MM-YYYY":
        dateFormatter = new Intl.DateTimeFormat(locale, {
          dateStyle: "short",
        });
        break;
      default:
        dateFormatter = new Intl.DateTimeFormat();
        break;
    }

    return dateFormatter.format(new Date(str));
  },
  ...
};

For example:

const now = new Date();

console.log(fmt`Today's date is ${now}:d(DD-MM-YYYY)`); 
// Today's date is 27/03/2022.

Number

The number formatter allows users to specify the number of fraction digits when displaying a number. In a way, it is a smarter .toFixed() function, as it is also aware of the formatting of numbers in different locales.

const formatters: Record<string, Function> = {
  ...
  n(str: string, digits: string): string {
    return new Intl.NumberFormat(locale, {
      minimumFractionDigits: Number(digits) || 0,
      maximumFractionDigits: Number(digits) || 0,
    }).format(Number(str));
  },
  ...
};

For example:

const num = 1_000_000;

// When the locale is set to "en-US"
console.log(fmt`${num}:n`) // 1,000,000

// When the locale is set to "en-IN"
console.log(fmt`${num}:n`) // 10,00,000

Relative Time

Displaying relative time has always been a bit of a pain, to the point where we are fine with importing a massive library just not to have to think about this! Luckily, now the Internationalization API provides formatters for that very use case, and that formatter is leveraged as well here!

const formatters: Record<string, Function> = {
  ...
  r(str: string, unit: Intl.RelativeTimeFormatUnit): string {
    return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
      Number(str),
      unit
    );
  },
  ...
};

For example:

const weeks = -1

console.log(fmt`Task was done ${weeks}:r(weeks).`); 
// Task was done last week.

String

The string formatters serves as the default formatter, but it allows provides some utility options, such as transforming a string to upper or lower case, while being locale-aware.

const formatters: Record<string, Function> = {
  ...
  s(str: string, format: string | null): string {
    if (!str) {
      return "";
    }

    switch (format) {
      case "U":
        return str.toLocaleUpperCase();
      case "l":
        return str.toLocaleLowerCase();
      default:
        return str.toLocaleString();
    }
  },
  ...
};

Time

The time formatter complements the date formatter by providing different options on top of the Intl.DateTimeFormat formatter to display the time of a date.

const formatters: Record<string, Function> = {
  ...
  t(str: string, format: string): string {
    let timeFormatter;
    switch (format) {
      case "HH:mm:ss TZ+":
        timeFormatter = new Intl.DateTimeFormat(locale, {
          timeStyle: "full",
        });
        break;
      case "HH:mm:ss TZ":
        timeFormatter = new Intl.DateTimeFormat(locale, {
          timeStyle: "long",
        });
        break;
      case "HH:mm:ss aa":
        timeFormatter = new Intl.DateTimeFormat(locale, {
          timeStyle: "medium",
          hour12: true,
        });
        break;
      case "HH:mm:ss":
        timeFormatter = new Intl.DateTimeFormat(locale, {
          timeStyle: "medium",
        });
        break;
      case "HH:mm aa":
        timeFormatter = new Intl.DateTimeFormat(locale, {
          timeStyle: "short",
          hour12: true,
        });
        break;
      case "HH:mm":
      default:
        timeFormatter = new Intl.DateTimeFormat(locale, {
          timeStyle: "short",
        });
        break;
    }

    return timeFormatter.format(new Date(str));
  },
  ...
};

For example:

const now = new Date();

console.log(fmt`The current time is: ${now}:t(HH:mm).`);
// The current time is: 12:00.

Weekday

Finally, the weekday formatter returns the date of the week given a certain date.

const formatters: Record<string, Function> = {
  ...
  w(str: string, format: string): string {
    let weekdayFormatter;
    switch (format) {
      case "WD":
        weekdayFormatter = new Intl.DateTimeFormat(locale, {
          weekday: "short",
        });
        break;

      case "WWDD":
      default:
        weekdayFormatter = new Intl.DateTimeFormat(locale, {
          weekday: "long",
        });
        break;
    }

    return weekdayFormatter.format(new Date(str));
  },
  ...
};

For example:

const now = new Date();

console.log(fmt`The day of the week today is ${now}:w(WWDD).`);
// The day of the week today is Sunday.

Internationalization

The Internationalization API, which we are leveraging for some formatters, uses the client's locale as a default. In most cases, that works as expected, but it might still be useful to surface a way to manually set that locale. The formatters we use from the Internationalization API all accept a locale as first argument, which facilitates our task here.

We keep track of the current locale used in the module by adding a locale variable as follows:

// Locale used when formatting template literals;
// defaults to host language settings
let locale: string | undefined;

We can then surface a fmt.use(locale) function to the users of the library to update the value of that variable:

fmt.use = function (_locale: string): void {
  locale = _locale;
};

That is sufficient for the formatters to get a closure on the value of the locale variable, so our added functionality works!

Nonetheless, for the maintainability of the code, it is a good idea to refactor a bit our code. Let's move all the formatters into factory functions that would get the locale as an parameter.

For example, for the currency formatter, that might look like this:

function createCurrencyFormatter(
  locale: string | undefined
): (str: string, currency: string) => string {
  return function c(str: string, currency: string): string {
    return new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
    }).format(Number(str));
  };
}

We can then create a function that would generate all the formatters we have previously defined:

function createFormatters(
  locale: string | undefined
): Record<string, Function> {
  return {
    c: createCurrencyFormatter(locale),
    d: createDateFormatter(locale),
    n: createNumericFormatter(locale),
    r: createRelativeTimeFormatter(locale),
    s: createStringFormatter(locale),
    t: createTimeFormatter(locale),
    w: createWeekdayFormatter(locale),
  };
}

And finally, we can wrap things up by using the createFormatters to initialize and update the formatters record:

let formatters = createFormatters(locale);

// ...

fmt.use = function (_locale: string): void {
  locale = _locale;
  formatters = createFormatters(locale);
};

That way, formatters are recreated whenever there is a locale change (which shouldn't be too often!).

Custom formatters

The default formatters already offer a decent range of options, but what if users need to format strings a certain way in their own projects? Of course, they can contribute their own formatters to the library, but that's not really scalable. Instead, let's add the possibility to extend the fmt library with custom formatters!

The API we want to surface to the users of this package is something as follows:

fmt.register("V", function (locale) {
  return function (str, option) {
    // Yes, you can use other formatters in custom formatters!
    return fmt`${str} version ${option}:n(1)`;
  };
});

const name = "Alice";

console.log(fmt`Welcome to ${name}:V(3)!`);
// "Welcome to Alice version 3.0!"

To avoid collisions with the current (and future) default formatters, we enforce the convention of using lowercase letters for formatters provided by the package, and uppercase letters for user-created formatters.

In regards to the implementation, the package stores user-created formatters in a record:

// Map to keep track of user-created hints and formatters
const userFormatters: Record<
  string,
  (locale?: string) => (str: string, option?: string) => string
> = {};

A utility function allows for adding new tags and formatters:

function addUserFormatter(
  tag: string,
  fn: (locale?: string) => (str: string, option?: string) => string
): void {
  userFormatters[tag] = fn;
}

That in turn is being called from a new method on the fmt function:

fmt.register = function (
  tag: string,
  fn: (locale?: string) => (str: string, option?: string) => string
): void {
  if (tag.length !== 1) {
    throw new Error(
      `User-created hint tags should contain a single character. Received ${tag.length} characters ("${tag}").`
    );
  }
  if (!/[A-Z]/.test(tag)) {
    throw new Error(
      `User-created hint tags should be a capital letter /[A-Z]/. Received "${tag}".`
    );
  }
  if (!(fn instanceof Function)) {
    throw new Error(
      `User-created hint tags should have an accompanying function.`
    );
  }
  if (!(fn() instanceof Function)) {
    throw new Error(
      `User-created hint tags should have an accompanying factory function.`
    );
  }

  addUserFormatter(tag, fn);

  formatters = createFormatters(locale);
};

Finally, to tie everything together, we need to update the fmtRegex regular expression and the createFormatters function:

// This now also includes capital letters to support user-created hints!
const fmtRegex = /^:([a-zA-Z])(\((.+)\))?/;

// ...

function createFormatters(
  locale: string | undefined
): Record<string, Function> {
  return {
    c: createCurrencyFormatter(locale),
    d: createDateFormatter(locale),
    n: createNumericFormatter(locale),
    r: createRelativeTimeFormatter(locale),
    s: createStringFormatter(locale),
    t: createTimeFormatter(locale),
    w: createWeekdayFormatter(locale),

    // User-created formatters
    ...Object.keys(userFormatters).reduce(
      (obj: Record<string, Function>, key: string) => {
        obj[key] = userFormatters[key](locale);
        return obj;
      },
      {}
    ),
  };
}

Memoization

To add the cherry on top (and do some sort of premature optimization!), you might have noticed that we are creating new Intl formatters every time we interpolate template strings with fmt. There might be no practical implications in terms of performance here, but if we can avoid extra computations, why not?

As such, let's add some memoization to our formatter factories! Concretely, that means that we will keep a record of the formatters created for specific inputs, and fetch the formatter from that record if it already exists. That will save use instantiating a new formatter on repeated calls!

Adding memoization to the currency formatter would look like this:

function createCurrencyFormatter(
  locale: string | undefined
): (str: string, currency: string) => string {
  const memo: Record<string, Intl.NumberFormat> = {};

  return function c(str: string, currency: string): string {
    const number = Number(str);

    if (isNaN(number)) {
      return "";
    }
    if (!currency) {
      return str;
    }

    if (!memo[currency]) {
      memo[currency] = new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      });
    }

    return memo[currency].format(number);
  };
}

Now, whenever we use the fmt template tag multiple times with the same currency, we will only instantiate a Intl.NumberFormat once and reuse it!


Next steps

We have covered a lot of ground in regards to the fmt-tag package and how it is internally structured. Software is never "done", but this library is already quite complete. Moving forward, there might be more built-in formatters added to the package, but that would be about it.

If you have ideas on how to improve the library, please feel free to open an issue! If you just want to have a look at the final code, it's all on GitHub: https://github.com/AntonioVdlC/fmt-tag!

I hope you found this exercise insightful!

Antonio Villagra De La Cruz

Antonio Villagra De La Cruz

Multicultural software engineer passionate about building products that empower people.