Template Literals and Tagged Templates

Mental model: A template literal is a string with holes. A tagged template hands you the pieces and the hole contents separately, so you can decide what goes in each hole.

Level: intermediate · about 13 minutes

const user = 'ada';
const items = 3;

console.log(`Hi ${user}, you have ${items} items.`);
console.log(`Total: ${items * 2} (double)`);      // any expression, not just a variable
console.log(`She said "it's fine"`);              // both quote styles, no escaping
console.log(`line one
line two`);                                       // the newline is literal

Backticks buy you three things: holes, real newlines, and unescaped quotes.

A template literal is delimited by backticks. Each ${...} hole is evaluated, converted to a string, and spliced in. The holes are full expressions, so calls, ternaries, member access and even another template literal are all fair game.

const cart = { items: ['tea'], total: 4.5 };

console.log(`${cart.items.length} item${cart.items.length === 1 ? '' : 's'}`);
// '1 item', pluralisation by ternary

console.log(`Outer ${`inner ${1 + 1}`}`);
// 'Outer inner 2', a template inside a hole

const rows = ['a', 'b'];
console.log(`<ul>${rows.map((r) => `<li>${r}</li>`).join('')}</ul>`);
// '<ul><li>a</li><li>b</li></ul>'
const items = ['a', 'b'];
console.log(`items: ${items}`);
console.log(`obj: ${{ id: 1 }}`);

Interpolation runs ToString. For an array that is join(","), giving a,b. For a plain object it is the inherited Object.prototype.toString, giving [object Object]. Neither is a bug, but neither is what you wanted to print either.

Indentation leaks into the string

What you wrote

function msg() {
  return `
    Hello
    World
  `;
}

What you got

'\n    Hello\n    World\n  '
// a leading newline, four spaces
// per line, and trailing spaces

The literal captures every character between the backticks, including the indentation of your source file. Either write the template flush left, or run it through a dedent tag (the second exercise in this lesson).

Tagged templates: the function call you cannot see

Put a function name directly in front of a template literal and you get a tagged template. The function receives the literal chunks as an array, and every interpolated value as a separate argument. No string was built for you, so you decide how the pieces join.

  tag`Hi ${name}, you are ${age}!`

  strings ->  [ "Hi ",  ", you are ",  "!" ]
  values  ->        [ name,        age ]

  strings.length === values.length + 1   (always)
function inspectTag(strings, ...values) {
  console.log('chunks:', strings);
  console.log('values:', values);
  console.log('counts:', strings.length, values.length);
  return 'tag ran';
}

const name = 'ada';
console.log(inspectTag`Hi ${name}, you are ${30 + 6}!`);

A tag that just shows you what it was given.

  1. Split at the holes The engine slices the literal into the text between the holes and passes that array first. An empty chunk appears when two holes touch or a hole sits at either end.
  2. Evaluate the holes Each expression runs once, left to right, before the tag is called. The tag receives the resulting values, not the source text.
  3. You do the joining Walk the chunks, and between them put whatever transformation you like: escaping, translating, collecting parameters, or nothing at all.
  4. Return anything A tag does not have to return a string. Query builders return an object with the SQL text and a parameter array. That is the whole trick behind safe SQL template tags.

raw: the characters you typed

function show(strings) {
  console.log('cooked:', JSON.stringify(strings[0]));
  console.log('raw:   ', JSON.stringify(strings.raw[0]));
}

show`a\nb`;
// cooked: "a\nb"  (a real newline, escape processed)
// raw:    "a\\nb" (backslash then n, exactly as typed)

console.log(String.raw`C:\new\table`.length); // 12, no escapes applied

The escaping tag

const escape = (v) => String(v)
  .replaceAll('&', '&amp;')
  .replaceAll('<', '&lt;')
  .replaceAll('>', '&gt;');

function html(strings, ...values) {
  return strings.reduce(
    (out, chunk, i) => out + (i ? escape(values[i - 1]) : '') + chunk,
    ''
  );
}

const comment = '<img onerror="steal()">';
console.log(html`<p>${comment}</p>`);
// '<p>&lt;img onerror="steal()"&gt;</p>'

The chunks are yours, the values are not. So escape only the values.

Tag you will meetWhat it returnsWhy a tag
String.rawthe string with escapes untouchedbuilt in, needs strings.raw
htmlescaped markup or a DOM fragmentescaping cannot be forgotten
sql{ text, params }values never reach the query text
css (styled-components)a class name plus injected rulesvalues become CSS custom properties
gqla parsed GraphQL documentparse once at module load
t (i18n)the translated stringthe chunks form the lookup key
hole contents
any expression, coerced with String()
`strings.length`
always values.length + 1
empty chunk
means two holes touched, or one hit an edge
`strings.raw`
the source characters, escapes not applied
return type
whatever you want, not just a string
evaluation
holes run left to right, before the tag is called

Escaping is not a step you remember to do. It is a property of the tool you build with.

The reason tagged templates exist

Try it yourself

Write your own tag

function upper(strings, ...values) {
  let out = strings[0];
  for (let i = 0; i < values.length; i += 1) {
    out += String(values[i]).toUpperCase() + strings[i + 1];
  }
  return out;
}

const who = ' ada ';
console.log(upper`hello ${who}, id ${42}`);

// A tag is just a function: call it by hand to see the shape.
console.log(upper(['a', 'b', 'c'], 1, 2));

Make upper also trim each value. Then write a csv tag that quotes any value containing a comma.

Cooked versus raw

function both(strings, ...values) {
  return {
    cooked: strings.join('|'),
    raw: strings.raw.join('|'),
    values,
  };
}

console.log(both`tab:\there${1}`);

const myRaw = (strings, ...values) =>
  strings.raw.reduce((out, chunk, i) => out + (i ? values[i - 1] : '') + chunk, '');

console.log(myRaw`a\nb${9}`);
console.log(String.raw`a\nb${9}`);

Change the escape to \u0041 and compare the two outputs. Then rebuild String.raw yourself and check it matches.

Exercises

A safe html tag

Write escapeHtml(value) which converts a value to a string and replaces &, <, >, " and ' with &amp;, &lt;, &gt;, &quot; and &#39;. Then write the tag html(strings, ...values) which joins the literal chunks untouched and passes every interpolated value through escapeHtml.

A dedent tag

Write dedent(strings, ...values). Join the template as usual, then: drop the first line if it is blank, drop the last line if it is blank, find the smallest leading-space count among the lines that are not blank, and remove exactly that many characters from the start of every remaining line. Blank lines in the middle stay blank.

Check yourself

What does this log?
3:2 — The chunks are ["a", "b", ""]: one before the first hole, one between the holes, and an empty one after the last hole. There is always exactly one more chunk than value, which is why 3:2.
What is the difference between `a\nb` and String.raw`a\nb` ?
The first has a real newline (3 characters), the second has a backslash and an n (4 characters) — A normal template processes escape sequences, so \n becomes one newline character. String.raw returns strings.raw, which holds the characters you typed, so the backslash survives. That is exactly what you want when writing a regex source or a Windows path.
Why is a tagged template the recommended way to build HTML from untrusted values?
It separates the code you wrote from the values you did not, so escaping can be applied to only the values, automatically — The chunks come from your source file and are trusted; the values may come from a user. Because the tag receives them separately, it can escape every value with no chance of you forgetting one. It does not stop you writing bad markup, and speed has nothing to do with it.

Common mistakes

  • Interpolating an object and shipping [object Object] to production.
  • Assuming the indentation in your source file is not part of the string. It is.
  • Building HTML or SQL with a plain template literal and user input.
  • Forgetting that strings has one more element than values, which produces an off-by-one in every hand written tag.

Takeaways

  • Holes take any expression and coerce the result with String().
  • A tag receives the chunks in one array and each value as a separate argument, with strings.length === values.length + 1.
  • strings.raw gives the characters as typed, which is what String.raw returns.
  • A tag can return anything, which is how sql tags return text plus parameters.
  • Escaping in a tag beats escaping by hand, because the tag cannot forget.