skip to content

A tagged template can escape interpolated values while a plain template literal cannot. Why is that structurally true, and what does an escaping tag still fail to protect you from?

level: seniorimportance: should knowfreq 38%

answer

  1. Code and data arrive in separate arguments
  2. Concatenation destroys the boundary too early
  3. Escaping happens per value, not per string
  4. Correctness depends on where the slot sits
  5. Parameterise instead of escaping when you can

basics

~20 s

A tag receives the author-written text and the runtime values as separate arguments, so it can transform only the values — a plain template has already concatenated them and cannot tell them apart. The remaining gap is context: escaping that is right inside element text is wrong inside an attribute, a URL, or a script.

solid answer

~50 s

The tagged form hands the function the static chunks in one argument and the interpolated values in the rest, so the trust boundary is explicit: everything in the chunks was typed by a developer, everything in the values came from somewhere else. That lets the tag escape, quote, or parameterise exactly the values and leave the surrounding text alone. A plain template literal has already concatenated the two into one string by the time you see it, and no later pass can reliably reconstruct which characters were data. The limit is that escaping is **context-sensitive**: HTML-escaping five characters is correct in element text but not inside an unquoted attribute, and it does nothing for a `javascript:` URL in an `href` or for a value spliced into an inline script. For queries the stronger move is not escaping at all — have the tag return the static text with numbered placeholders plus the values array, so the data never becomes part of the statement.

code

javascript · 16 lines
javascript
const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ESCAPES[c]);

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

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

// Still unsafe: the escaper touches nothing in this value.
console.log(html`<a href="${'javascript:alert(1)'}">x</a>`);

go deeper

for a junior

Know that a plain backtick string does no escaping at all, and that a tag function is the hook where escaping can happen because it sees the values separately.

for a middle

Explain the mechanism: the tag transforms each interpolated value while leaving the author-written chunks untouched, and be able to write that reduce loop.

for a senior

Demonstrate the production judgment — name the contexts where one escaper is not enough, explain double-escaping and the wrapper-object fix, and prefer parameterisation to escaping.

for a principal

Own the policy: which construction paths must go through the house tag, how the unsafe path is detected in review or lint, and how an opt-out is kept rare and auditable.

## The structural argument Injection bugs are confusions between *code* and *data*. A string built by concatenation has lost that distinction irreversibly: ```js const html = `<p>${comment}</p>`; // which characters were data? unknowable afterwards ``` A tagged template preserves it at the exact moment it still exists. The tag is called with the literal chunks (from the source file, therefore trusted) as one argument and the evaluated values (from a request, a database, a user) as the others. Escaping applied there is applied to precisely the untrusted part, automatically, at every call site that uses the tag — the developer cannot forget, because using the tag *is* the escaping. ```js const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }; const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ESCAPES[c]); function html(strings, ...values) { return strings.reduce( (out, chunk, i) => out + chunk + (i < values.length ? esc(values[i]) : ''), '' ); } ``` This is the shape every real library of this kind has: a per-value transform, folded over the chunks. ## What it genuinely buys - **Default safety.** The unsafe path (raw concatenation) and the safe path (the tag) differ by one identifier, and code review can grep for the unsafe one. - **Composability of trust.** A tag can recognise values that are already-escaped results of the same tag — typically by returning a wrapper object rather than a bare string — and splice them without double-escaping, which naive escaping gets wrong. - **Non-string outputs.** Nothing forces the tag to return text. A query tag can return `{ text, values }` where `text` carries numbered placeholders and the data is passed separately to the driver; that is not escaping at all, it is parameterisation, and it is strictly stronger. ## What it does not fix **Context.** The five-character HTML escape is correct for element text. It is not sufficient in these places, all of which look identical at the call site: - An unquoted attribute — a space or `/` in the value ends the attribute and starts a new one; escaping quotes does not help. - A URL-valued attribute — `javascript:alert(1)` contains no character the escape touches, so the escaped value is still an executable URL. URL-valued positions need scheme allow-listing, not character escaping. - Inside an inline script or a style block — the parsing rules there are not HTML's, and `<`/`&` escaping is both wrong and insufficient. A tag that does not know *where* in the template each placeholder sits therefore cannot escape correctly for all of them. Serious libraries solve this by parsing the static chunks once (they can afford to: the chunks are per-call-site and stable) and choosing a per-position escaper. A hand-rolled tag almost never does that, so its safety claim quietly holds only for element-text positions. **Values that are meant to be structural.** Users eventually want to interpolate a fragment, a column name, an operator. The moment the tag grows an opt-out — a `raw()` wrapper, a trusted-value marker — the guarantee becomes a convention again, and the opt-out is where the bug will be. Design it to be visible and rare, and search for it in review. **Everything downstream.** The tag protects the *construction* of the string. What happens to that string afterwards is outside its knowledge: written somewhere that reinterprets it, logged, stored and later re-emitted through a different path, or concatenated with another string by the caller. ## Practical guidance Use a tag when the same kind of string is built in many places and correctness must not depend on memory: markup fragments, queries, command lines, log lines needing sanitisation. Prefer a tag that returns a **structured value** over one that returns an escaped string whenever the consumer supports it, because parameterisation removes the failure mode instead of mitigating it. State the tag's contract in one line at its definition — which context it is safe for — and treat any other use as a defect. And keep the tag boring: a tag that also formats, trims, and dedents becomes hard to reason about, and the safety property is the one you cannot afford to lose.

  • How would you stop an escaping tag from double-escaping a value that another call to the same tag produced?
    Have the tag return a wrapper object — a small class holding the built text — rather than a bare string, and check for that wrapper before escaping a value. A wrapper instance is spliced verbatim; anything else is escaped. Returning a bare string makes already-safe and never-checked text indistinguishable, which is exactly the confusion the tag exists to remove.
  • Why is returning a parameterised object stronger than returning an escaped query string?
    Because the data never becomes part of the statement text at all: the tag emits the static chunks joined by numbered placeholders and hands the values across separately, so no quoting rule has to be correct. Escaping asks you to model the target grammar perfectly; parameterisation removes the grammar from the question.
  • Where does a single-escaper tag stop being safe in HTML?
    Anywhere the surrounding grammar is not element text: unquoted attributes, where whitespace ends the attribute; URL-valued attributes, where a javascript: scheme contains no escapable character; and inline script or style content, which is not parsed as HTML at all. Handling those requires knowing each placeholder's position, which means parsing the static chunks.

saying these in an interview costs you the question

  • Says any escaping tag makes output safe everywhere
  • Escapes the static chunks instead of the values
  • Thinks HTML escaping neutralises a javascript: URL
  • Treats escaping and parameterisation as equivalent
  • Adds a raw opt-out without restricting its use

context