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 literalBackticks 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 spacesThe 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.
- 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.
- 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.
- You do the joining Walk the chunks, and between them put whatever transformation you like: escaping, translating, collecting parameters, or nothing at all.
- 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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>');
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><img onerror="steal()"></p>'The chunks are yours, the values are not. So escape only the values.
| Tag you will meet | What it returns | Why a tag |
|---|---|---|
String.raw | the string with escapes untouched | built in, needs strings.raw |
html | escaped markup or a DOM fragment | escaping cannot be forgotten |
sql | { text, params } | values never reach the query text |
css (styled-components) | a class name plus injected rules | values become CSS custom properties |
gql | a parsed GraphQL document | parse once at module load |
t (i18n) | the translated string | the 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.
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 &, <, >, " and '. 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 why3:2. - What is the difference between
`a\nb`andString.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
\nbecomes one newline character.String.rawreturnsstrings.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
stringshas one more element thanvalues, 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.rawgives the characters as typed, which is whatString.rawreturns.- A tag can return anything, which is how
sqltags return text plus parameters. - Escaping in a tag beats escaping by hand, because the tag cannot forget.