Tagged templates
Write a function name directly before a template literal and the literal becomes a call to it: html`<p>${name}</p>`. The function receives the static text as one argument and the interpolated values as the rest. Nothing is joined for it.
That separation is the entire feature. Because a tag can see which parts came from the source and which came from data, it can escape, validate, or transform the data without trusting whoever wrote the call site to remember.
Create programs/tagged-templates.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assert, assertEquals } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
the tag receives text and values separately
The file opens with a tag that does nothing but show what arrived. inspect copies its two views of the text and collects the values:
function inspect(strings: TemplateStringsArray, ...values: unknown[]) {
return { cooked: [...strings], raw: [...strings.raw], values };
}
Deno.test("the tag receives text and values separately", () => {
assertEquals(inspect`a${1}b`, {
cooked: ["a", "b"],
raw: ["a", "b"],
values: [1],
});
});
Check programs/tagged-templates.test.ts
running 1 test from ./programs/tagged-templates.test.ts
the tag receives text and values separately ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
Read the anatomy off the result.
- The first parameter holds the text fragments between the substitutions:
"a"and"b", with the1cut out from between them. Its type,TemplateStringsArray, carries the second view,.raw, which a later step needs. - The remaining parameters are the interpolated values, in order, arriving as themselves rather than as text.
valuesholds the number1, not the string"1".
The text and the data arrive in separate hands, and it is up to the tag to put them together, or not.
always one more string than value
The two arrays keep a fixed relationship, and its edge case is worth a wrong prediction. What is cooked for a literal that starts with a substitution? Save:
Deno.test("always one more string than value", () => {
assertEquals(inspect`plain`.cooked.length, 1);
assertEquals(inspect`plain`.values.length, 0);
const three = inspect`${1}${2}${3}`;
assertEquals(three.cooked.length, 4);
assertEquals(three.values.length, 3);
assertEquals(inspect`${1}b`.cooked, ["b"]);
});
Check programs/tagged-templates.test.ts
running 2 tests from ./programs/tagged-templates.test.ts
the tag receives text and values separately ... ok (0ms)
always one more string than value ... FAILED (7ms)
ERRORS
always one more string than value => ./programs/tagged-templates.test.ts:16:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
- "",
"b",
]
FAILURES
always one more string than value => ./programs/tagged-templates.test.ts:16:11
FAILED | 1 passed | 1 failed (10ms)
error: Test failed
An empty string appears before the "b". The invariant explains it: there is always exactly one more string than value. No substitutions means one string; three substitutions in a row means four strings, two of them empty in the middle; and a literal that begins or ends with a substitution gets an empty string in that position rather than a shorter array. Correct the prediction to ["", "b"]:
always one more string than value ... ok (0ms)
The invariant is what lets a tag walk both arrays in a single pass, taking one chunk of text and one value alternately, without ever checking bounds. The html tag two steps down does exactly that.
a tag may return anything
A tag is an ordinary function, and nothing says it must return a string:
Deno.test("a tag may return anything", () => {
const count = (_strings: TemplateStringsArray, ...values: unknown[]) =>
values.length;
assertEquals(count`a${1}b${2}`, 2);
});
a tag may return anything ... ok (0ms)
count`a${1}b${2}` is a number. That freedom is how libraries return DOM nodes, prepared statements, or compiled queries from something that reads like a string in the source.
the escaping is the point
Here is the feature earning its keep. The strings page's the replacement string has its own syntax showed a small injection through replace; interpolating user data into markup is the big one, and the baseline line below shows it passing through a plain template untouched:
Deno.test("the escaping is the point", () => {
function escapeHtml(text: string): string {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function html(strings: TemplateStringsArray, ...values: unknown[]): string {
return strings.reduce(
(out, chunk, i) =>
out + chunk + (i < values.length ? escapeHtml(String(values[i])) : ""),
"",
);
}
const fromUser = '<script>alert("x")</script>';
assertEquals(`<p>${fromUser}</p>`, '<p><script>alert("x")</script></p>');
assertEquals(
html`<p>${fromUser}</p>`,
"<p><script>alert("x")</script></p>",
);
});
the escaping is the point ... ok (0ms)
Compare the two assertions.
- The plain template literal joins text and data blindly, so the script tag from the user lands in the markup as markup. Building the string by hand makes escaping the caller's job, and forgetting it produces working code with a hole in it.
- The
htmltag walks its two arrays with the one-more-string invariant: each chunk of source text passes through untouched, and each value is escaped on its way in. The<p>survives and the injected script does not.
Here the escaping cannot be forgotten, because there is no way to pass a value into the output without it going through the tag. That is why html, sql, graphql, and css tags exist across the ecosystem: the tag is a boundary, and interpolation is the only way across it.
cooked text and raw text
Every tag gets the text twice. cooked has escape sequences resolved; .raw is the same text with the backslashes left alone:
Deno.test("cooked text and raw text", () => {
const tabbed = inspect`\tab`;
assertEquals(tabbed.cooked, ["\tab"]);
assertEquals(tabbed.raw, ["\\tab"]);
assertEquals(tabbed.cooked[0].length, 3);
assertEquals(tabbed.raw[0].length, 4);
assertEquals(String.raw`\n`, "\\n");
assertEquals(String.raw`\n`.length, 2);
assertEquals("\n".length, 1);
assertEquals(String.raw`C:\Users\Robin`, "C:\\Users\\Robin");
assertEquals(new RegExp(String.raw`^\.`).test(".hidden"), true);
});
cooked text and raw text ... ok (0ms)
Two views of one source.
- In
`\tab`, the cooked entry holds a real tab character followed byab, three characters. The raw entry holds a backslash, at, andab, four characters. Same source, two readings. String.rawis the raw view packaged as a built-in tag, and it exists for text whose backslashes belong to some other parser. A regular expression source and a Windows path are the two cases you will meet, and the last two lines are exactly those: the path keeps its backslashes, and^\.reachesnew RegExpintact instead of having JavaScript interpret the\.first.
text that is not valid JavaScript at all
Because a tag may accept a language with its own escape rules, the cooked view is allowed to fail:
Deno.test("text that is not valid JavaScript at all", () => {
const broken = inspect`\uu ${1}`;
assertEquals(broken.cooked[0], undefined);
assertEquals(broken.raw[0], "\\uu ");
});
text that is not valid JavaScript at all ... ok (0ms)
\uu is not a legal escape, so the cooked entry is undefined while the raw text survives intact. Notice the design: the language is using undefined as an in-band marker for "no valid interpretation", exactly the trade the sentinels page priced, and it is safe here because a cooked entry is otherwise always a string. Before ES2018 this was a syntax error and the whole literal was rejected, which made tags for LaTeX or Windows paths impossible to write. Now a tag that reads .raw accepts text a plain template literal would refuse, and the invalid cooked entry costs nothing.
the strings array is cached per call site
One more property, and a whole rendering strategy depends on it. Two functions with byte-identical tagged literals. Predict whether their arrays are ===:
Deno.test("the strings array is cached per call site", () => {
const grab = (strings: TemplateStringsArray) => strings;
function callSiteA() {
return grab`same text`;
}
function callSiteB() {
return grab`same text`;
}
assertEquals(callSiteA() === callSiteA(), true);
assertEquals(callSiteA() === callSiteB(), true);
assert(Object.isFrozen(callSiteA()));
});
Check programs/tagged-templates.test.ts
running 7 tests from ./programs/tagged-templates.test.ts
...
the strings array is cached per call site ... FAILED (8ms)
ERRORS
the strings array is cached per call site => ./programs/tagged-templates.test.ts:81:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- false
+ true
FAILURES
the strings array is cached per call site => ./programs/tagged-templates.test.ts:81:11
FAILED | 6 passed | 1 failed (10ms)
error: Test failed
The identity rules run opposite to the text. Correct the second prediction to false and read all three lines together:
the strings array is cached per call site ... ok (0ms)
- Calling the same tagged template twice hands the tag the identical array object, not an equal copy:
callSiteA() === callSiteA()by the identity rules of===on objects compares identity, not contents. - Two different call sites with byte-identical text get different arrays. The array's identity is therefore a stable key for the location in the source, and that is what template-based rendering libraries build on: the first time they see a call site they compile it, and afterwards they recognize it by identity and update only the values.
- The array and its
.raware frozen, by the sameObject.freezethe values-and-references page measured, so nobody can tamper with the key.
fixing the indentation a literal picks up
A multiline template literal captures the indentation of the source it sits in, which is right for the code and wrong for the output. A tag can strip it:
Deno.test("fixing the indentation a literal picks up", () => {
function dedent(strings: TemplateStringsArray, ...values: unknown[]): string {
const joined = strings.reduce(
(out, chunk, i) =>
out + chunk + (i < values.length ? String(values[i]) : ""),
"",
).replace(/^\n/, "");
const lines = joined.split("\n");
let indent = Infinity;
for (const line of lines) {
if (line.trim() === "") continue;
indent = Math.min(indent, line.length - line.trimStart().length);
}
return lines.map((line) => line.slice(indent)).join("\n");
}
function markup(content: string): string {
return `
<div>
${content}
</div>`;
}
function dedented(content: string): string {
return dedent`
<div>
${content}
</div>`;
}
assert(markup("Hello").startsWith("\n <div>"));
assertEquals(dedented("Hello"), "<div>\n Hello\n</div>");
});
fixing the indentation a literal picks up ... ok (0ms)
The two functions hold the identical literal, and only the tag differs.
markupreturns the text as written: a leading newline and six spaces of source indentation on every line, which the output has no use for.dedentjoins the parts with the same reduce as thehtmltag, drops the leading blank line, finds the smallest indentation among the non-blank lines, and removes that much from each. What remains is the shape you meant:<div>, a two-space-indentedHello,</div>.
.trim() is the cheap alternative, but it only fixes the ends, so escaping the full indentation that way forces the content to start in the leftmost column of your source, which reads badly inside an indented function. The tag lets the source stay readable and the output stay clean.
In practice
- Use a tag when values entering another language need consistent treatment, and let the tag own that treatment.
- Walk the arrays using the invariant that there is one more string than value.
- Use
.rawwhen the text belongs to another parser, andString.rawwhen you only need to preserve backslashes. - Use the stable strings-array identity as a cache key in a library that renders the same call site repeatedly.
- Keep plain template literals for ordinary interpolation; an unnecessary tag adds indirection.