JavaScript Tagged Templates

Tagged templates are similar to template string literals but provide more fine-grained control over how interpolation happens and can return any object, not just strings.

They are used in many modern libraries and frameworks, to name a few: styled-components (a CSS-in-JS library), htm (JSX-like template engine), sqorn (an SQL query builder), and many others.

Let's see how tagged templates work and why they are useful.

What's a tagged template?

Tagged templates look like a usual template literals with a "tag" prefix. That tag is a simple function that receives string parts and the interpolations as separate arguments.

const t = (...args) => { return args }
console.log(t`Hello, ${name}!`)
//=> [["Hello, ", "!"], "John"]

Example 1: Let's create some objects

Let's create a tag that takes a string with key-value pairs, separated with a comma, and then converts it to an object. The output of the tagged template can be anything.

It should work like this.

const o = obj`name:John,surname:Smith,phone:555`
console.log(o)
//=> {name: 'John', surname: 'Smith', phone: '555'}

To make it work, we create a simple function that takes a string, splits it with a comma, then uses reduce to collect an object.

function obj([str]) {
  // we take only the first argument of the array
  // since we expect no interpolations
  return str
    .split(",")
    //=> ["name:John", "surname:Smith", "phone:555"]
    .map(s => s.split(":"))
    //=> [["name", "John"], ["surname", "Smith"], ["phone", "555"]]
    .reduce((acc, [k, v]) => {
      acc[k] = v
      return acc
    }, {})
}


const o = obj`name:John,surname:Smith,phone:555`
console.log(o)
console.log(obj`name:John,surname:Smith`)
//=> {name: 'John', surname: 'Smith', phone: '555'}

Nice! Now let's try this.

const name = "John"
const surname = "Smith"
console.log(obj`name:${name},surname:${surname}`)
//=> {name: '', surname: ''}

That's a bit unexpected. Did you guess what happened?

We ignored the interpolations!

As noted earlier, the tag template function receives all the interpolated strings as function arguments.

It's our job now to do the interpolation.

Let's fix this.

function obj(strs, ...interpolations) {
  // let's combine raw strings and interpolations
  // into a full string first
  let fullString = strs[0]
  for (let i = 0; i < interpolations.length; i++) {
    fullString += (interpolations[i] + strs[i])
  }

  return fullString
    .split(",")
    .map(s => s.split(":"))
    .reduce((acc, [k, v]) => {
      acc[k] = v
      return acc
    }, {})
}

Let's try again.

const name = "John"
const surname = "Smith"
console.log(obj`name:${name},surname:${surname}`)
//=> {name: 'name', surname: 'Smith'}

Now we're talking!

Example 2: HTML escaper

We all know that we should be extra careful when inserting user-defined input into HTML to avoid the XSS atack.

const code = "<script>alert('I am malicisous');</script>"
console.log(`Your name is <b>${code}</b>`)
//=> Your name is <b><script>alert('I am malicisous');</script></b>

Ooops.

Can we sanitize the input while doing the string interpolation?

Of course we can! That's what intterpolation is for.

function html(strs, ...subs) {
  // the strs array is always exactly
  // one element larger then subs
  let res = strs[0];
  for (let i = 0; i < subs.length; i++ ) {
    // we only escape the substitutions
    // and do not alter the regular strings
    res += (escapeHTML(subs[i]) + strs[i])
  }
  return res;
}

function escapeHTML(str) {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

console.log(html`Your name is <b>${code}</b>`)
//=> Your name is <b>&lt;script&gt;alert(&#039;I am malicisous&#039;);&lt;/script&gt;Your name is <b>

Note that we could not achieve the same result using a plain function without introducing some sort of "interpolation syntax".

// maybe like this
html('Your name is <b>%s</b>', code })

// or this?
html('Your name is <b>$code</b>', { code: code })

// or this?
html('Your name is <b>?</b>', code)

The interface can differ depending on author's preference.

Tagged templates give us a standard syntax to use.

Example 3: An AJAX request

We've already learned that tagged templates don't have to return a string; they can produce anything.

What is also interesting is that substitutions also can be anything.

Imagine we want to create a tagged template that gets resolved into a fetch request.

const repos = await get`https://api.github.com/search/repositories ${{q: "JavaScript"}}`

Can we do that? Yes, we can.

function get([baseUrl], query) {
  // concat base url and query (which needs
  // to be converted and escaped first)
  const url = baseUrl.trim() + "?" + toQueryStr(query)
  return fetch(url).then(res => res.json())
}

// takes and object and converts it intro a quest string like
// hello=world&q=abc&...
function toQueryStr(obj) {
  let str = [];
  for (let p in obj)
    if (obj.hasOwnProperty(p)) {
      // encoding query params while we're at it!
      str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
    }
  return str.join("&");
}

await get`https://api.github.com/search/repositories
${{
  language: "javascript",
  q: "tagged template",
  sort: "stars",
  order: "desc"
}}`
//=> {total_count: 1887, incomplete_results: false, items: Array(30)}

Neat, isn't it?

Where to next?

Hopefully, this article gave you a taste of tagged templates in JavaScript, and now you know how and why.

If you want to dive a bit deeper, here are some links you might find helpful.

🔥 100+ questions with answers
🔥 50+ exercises with solutions
🔥 ECMAScript 2023
🔥 PDF & ePUB