12k
All articles

Tagged Template Literals: Building Mini-DSLs in JavaScript

Tagged template literals in JavaScript explained with cooked vs raw strings, WeakMap caching, safe HTML, SQL, and mini-DSL examples.

OpenReplay Team
OpenReplay Team
Tagged Template Literals: Building Mini-DSLs in JavaScript

A tagged template literal is a function call whose arguments come from a template literal: the runtime splits the literal at every ${...} boundary, hands the static string segments and the evaluated interpolations to a function you name, and lets that function return whatever it wants. That last clause is the whole game. When you write styled.div`...` or gql`...`, you are calling a function that receives the template’s pieces and returns something that is not a string — a React component, a parsed GraphQL document. This article explains the call mechanic exactly, then uses it to build four working mini-DSLs, including a parameterized SQL tag that resists value-based injection by construction.

You have used this syntax. What follows is how it works and how to build your own.

Key Takeaways

  • A tagged template tag`...` is syntactic sugar for tag(strings, ...values), where strings is an array of the static segments and ...values are the interpolated expressions evaluated left-to-right.
  • The strings array always has exactly one more element than valuesstrings.length === values.length + 1 — because a template literal always begins and ends with a string segment, even if empty.
  • Tag functions are not required to return strings: styled-components returns a React component, graphql-tag returns a DocumentNode AST, and lit-html returns a TemplateResult.
  • Because the same frozen template object is reused on every evaluation of a specific tagged template, a WeakMap keyed by the strings array can cache parsed work for that literal.
  • A safe SQL tag prevents value-based SQL injection by turning interpolations into query parameters instead of concatenating them into SQL text — provided it always parameterizes values and never splices raw SQL or identifiers.

The Call Signature: tag`...` Desugars to tag(strings, ...values)

A tagged template is syntactic sugar for an ordinary function call. The expression tag`Hi, ${name}!` is equivalent to calling tag with the static segments as the first argument and each interpolated value as a subsequent argument. The two forms below are roughly equivalent for illustrating the call signature:

const name = "Zach";

// Tagged template literal
tag`Hi, ${name}!`;

// The desugared equivalent
tag(["Hi, ", "!"], name);

The first parameter is an array of the static string segments, split at each ${...}. The remaining arguments are the interpolated values, which you collect with a rest parameter. (MDN’s tagged templates documentation describes this call shape.)

function tag(strings, ...values) {
  console.log(strings); // ["Hi, ", "!"]
  console.log(values);  // ["Zach"]
}

const name = "Zach";
tag`Hi, ${name}!`;

Interpolations are evaluated left-to-right at call time and passed as their real values — not stringified. A tag receives a function object, an array, or an object exactly as written:

function tag(strings, ...values) {
  return values;
}

tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — actual values, not strings

This is the first practical difference from a plain template literal, which always coerces interpolations to strings and always returns a string. A tag intercepts the pieces before any coercion happens.

The strings.length === values.length + 1 Invariant

The strings array always has exactly one more element than the values array — strings.length === values.length + 1 — because a template literal always begins and ends with a string segment, even if that segment is an empty string. A literal with three interpolations has four string segments; a literal that starts with ${x} simply has an empty string as its first segment:

function tag(strings, ...values) {
  console.log(strings.length, values.length);
}

tag`${1}${2}`;
// strings: ["", "", ""]  →  3
// values:  [1, 2]        →  2

This invariant is why reducing over strings is the cleanest way to interleave segments and values: walk the strings, and append values[i] after each one except the last.

Cooked vs. Raw: When the Difference Is Load-Bearing

The strings array holds the cooked interpretation, where \n is a newline and \t is a tab. The strings.raw property holds the raw source characters, where \n is the two-character sequence backslash-n. This distinction matters whenever your DSL processes text that uses backslashes structurally — regex patterns, LaTeX markup, or Windows file paths.

function showBoth(strings) {
  console.log(strings[0]);     // cooked: a real newline
  console.log(strings.raw[0]); // raw: the characters \ and n
}

showBoth`line\nbreak`;
// cooked → "line", then "break" on the next line
// raw    → "line\nbreak"

JavaScript ships exactly one built-in tag for this purpose: String.raw. It returns the raw interpretation of the literal, so escape sequences survive as literal characters. (MDN’s String.raw reference documents it as the only built-in template tag; it behaves like an identity tag only when the literal contains no escape sequences.)

const winPath = String.raw`C:\Users\dev\project`;
console.log(winPath); // "C:\Users\dev\project" — backslashes preserved

const pattern = new RegExp(String.raw`\d{3}-\d{4}`);
pattern.test("555-1234"); // true — no double-escaping needed

Without String.raw, the regex source would need \\d{3}-\\d{4} to survive cooking. Any tag you write can offer the same behavior by reading from strings.raw instead of strings.

Tag Functions Can Return Anything

A tag function is just a function, and its return value is whatever the author decides. This is the single insight that turns tagged templates from a string-formatting curiosity into a tool for building domain-specific languages. The static segments and values are raw material; the tag decides what to construct from them. styled-components returns a React component, graphql-tag returns a DocumentNode AST, and lit-html returns a TemplateResult.

The four DSLs below escalate from string output to parameterized query objects, demonstrating return types that no plain template literal can produce.

An HTML-Escape Tag (Defeating XSS)

An HTML-escape tag wraps every interpolated value in HTML-entity encoding before it reaches the DOM, so user input cannot break out of its text context. The bug it prevents: interpolating untrusted input directly into markup is a stored or reflected XSS vector — a payload in the value executes when the string is inserted into the DOM:

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = `<div>${userInput}</div>`;  
// element.innerHTML = markup → the onerror handler executes

A safeHtml tag intercepts each interpolation and escapes it before it reaches the output, while leaving the static segments — which the developer authored, not the user — untouched:

function escapeHtml(value) {
  return String(value)
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

function safeHtml(strings, ...values) {
  return strings.reduce((acc, segment, i) => {
    const value = i < values.length ? escapeHtml(values[i]) : "";
    return acc + segment + value;
  }, "");
}

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = safeHtml`<div>${userInput}</div>`;  
// "<div>&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;</div>" — inert

The asymmetry is the point: static segments are trusted (you wrote them), interpolations are not (they may carry user input), and the tag is the one place that distinction is enforced.

A Safe-SQL Tag (Parameterized Queries by Construction)

A safe-SQL tag prevents value-based SQL injection by turning interpolations into query parameters instead of concatenating them into SQL text. The string-concatenation version of a query is the canonical SQL injection bug — concatenating a value into the query text lets that value change the query’s structure:

// BROKEN — do not use
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → returns every row

A safe SQL tag prevents value-based SQL injection by turning interpolations into query parameters instead of concatenating them into SQL text — provided the tag always parameterizes interpolated values and never splices raw SQL or identifiers into the query. The tag returns a { text, values } object: the text field carries placeholders ($1, $2, …), and values carries the actual data, kept separate from the query structure:

function sql(strings, ...values) {
  const text = strings.reduce((acc, segment, i) => {
    return acc + segment + (i < values.length ? `$${i + 1}` : "");
  }, "");
  return { text, values };
}

const id = "1 OR 1=1";
const query = sql`SELECT * FROM users WHERE id = ${id}`;
// {
//   text: "SELECT * FROM users WHERE id = $1",
//   values: ["1 OR 1=1"]
// }

That { text, values } shape is exactly what node-postgres accepts as a parameterized query: client.query(query) passes the query text and parameter values separately to PostgreSQL, and the database never interprets the parameter value as SQL syntax. The malicious string "1 OR 1=1" is treated as a single parameter value, not as SQL syntax, so it cannot become part of the query structure.

The reason this is more defensible than the string-concat version is structural: with the tag, an interpolated value can only ever become a parameter. There is no syntax in the tagged form that splices a value into the query text. The safety holds for values; it does not extend to dynamic table or column names, which are identifiers and must be validated separately rather than interpolated.

An i18n Tag with WeakMap Identity Caching

Because the same frozen template object is reused on every evaluation of a specific tagged template — a behavior defined in the ECMAScript specification — a WeakMap keyed by the strings array can cache parsed work for that literal: parse it once, and reuse the cached result on subsequent evaluations. This is the identity trick that lets template-based libraries avoid re-parsing the same template on every render.

The reuse is observable. The same strings array is referentially identical across calls to the same tagged template:

let first, second;
function capture(strings) {
  return strings;
}

function render() {
  return capture`hello ${1} world`;
}

first = render();
second = render();
console.log(first === second); // true — same frozen array object

You can verify this in any browser console or Node.js REPL; it is the spec-defined reuse of the template object, not an implementation quirk. (The ECMAScript specification’s template-evaluation semantics define that the same template object is returned for a given template literal.)

A WeakMap keyed on strings turns that into a cache. Here, an i18n tag pre-computes the format string once per literal and reuses it:

const cache = new WeakMap();

function t(strings, ...values) {
  let key = cache.get(strings);
  if (key === undefined) {
    // expensive work done once per unique literal
    key = strings.join("{}");
    cache.set(strings, key);
  }
  return lookupTranslation(key, values);
}

A WeakMap holds its keys weakly, so cache entries are eligible for collection once the template object is no longer referenced — no manual eviction, no string-key collisions.

A CSS-in-JS Style Tag

A style tag can return a plain object that the consumer composes elsewhere, mirroring how styled-components and Emotion turn a CSS literal into something a component system consumes. Instead of returning a string, the tag parses declarations into a key-value object:

function css(strings, ...values) {
  const cssText = strings.reduce(
    (acc, segment, i) => acc + segment + (values[i] ?? ""),
    ""
  );
  return Object.fromEntries(
    cssText
      .split(";")
      .map((d) => d.trim())
      .filter(Boolean)
      .map((d) => {
        const [prop, val] = d.split(":").map((s) => s.trim());
        return [prop, val];
      })
  );
}

const styles = css`
  display: flex;
  align-items: center;
`;
// { display: "flex", "align-items": "center" }

This is the shape of the real thing. In styled-components, styled.div`...` returns a React component; the styled-components documentation covers the full API. The mechanic is identical to the toy above — a tag function consuming a CSS literal and returning a non-string value the framework knows how to use.

Real-World Libraries Built on Tagged Templates

The libraries below all use tagged templates as their public API. Each tags a different language and returns a different type, which is the clearest demonstration that the return value is the author’s choice.

LibraryWhat you tagWhat it returnsUse case
styled-componentsCSSA React componentComponent-scoped CSS-in-JS
EmotionCSSA class name / styled componentCSS-in-JS with object and tagged-template APIs
lit-htmlHTMLA TemplateResultEfficient DOM rendering
graphql-tagGraphQLA DocumentNode ASTParsing queries for GraphQL clients
sql-template-stringsSQLA prepared-statement objectParameterized SQL (note: no longer actively maintained)
common-tagsTextFormatted stringsReusable template-literal tag functions for ES2015+

The TypeScript Signature for a Tag Function

The TypeScript signature for a tag function is function tag(strings: TemplateStringsArray, ...values: unknown[]): T, where TemplateStringsArray is typed as a ReadonlyArray<string> with an additional readonly raw: readonly string[] member. The type is built into TypeScript’s standard library and requires no import.

// Generic shape of a tag function
type Tag<T> = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => T;

// Applied to the safe-sql tag:
interface SqlQuery {
  text: string;
  values: unknown[];
}

function sql(strings: TemplateStringsArray, ...values: unknown[]): SqlQuery {
  const text = strings.reduce(
    (acc, segment, i) => acc + segment + (i < values.length ? `$${i + 1}` : ""),
    ""
  );
  return { text, values };
}

TemplateStringsArray is defined in lib.es5.d.ts and has shipped since early TypeScript releases. (The TypeScript 1.5 release notes use TemplateStringsArray in their tagged-template example.) Note that the readonly modeling here is a type-level description: the runtime freezing of the template object is a separate ECMAScript behavior, not something the TypeScript type causes.

When Not to Use Tagged Templates

If your DSL requires a real grammar — operator precedence, lookahead, or recursive structures — tagged templates are the wrong tool. The tag function receives a flat list of string segments and evaluated values, not a parse tree, so anything that needs more than left-to-right evaluation of interpolations belongs in a parser combinator or PEG grammar. Reach for a dedicated parser such as nearley or Peggy when you hit a real language.

Three concrete signals that you have outgrown tagged templates:

  1. You need a grammar. Precedence, nested scopes, or backtracking cannot be expressed as a flat strings-and-values split. Use a parser.
  2. Evaluation order beyond left-to-right matters. Interpolations are evaluated left-to-right at call time, before the tag runs. A tag cannot defer or reorder them — it only sees results.
  3. You are inside a hot loop. Interpolated expressions are evaluated on every call even though the same template object is reused, so an expensive interpolation inside a tight render loop pays its cost every iteration. The strings array is cached by identity; the values are not.

For everything between plain interpolation and a full language — escaping, parameterizing, keying a cache, building a styled component — tagged templates are the right size of tool.

Start with the safe-sql tag. It is small enough to read in one sitting, returns a { text, values } object compatible with node-postgres, and demonstrates the property that makes tagged templates worth reaching for: a value crossing a ${...} boundary can be forced into a safe role that string concatenation can never guarantee.

FAQs

Why is strings.length always one more than values.length in a tagged template?

A template literal always begins and ends with a string segment, even when that segment is empty, so the static segments bracket every interpolation on both sides. With n interpolations there are n plus 1 string segments, giving the invariant strings.length === values.length + 1. A literal like tag-backtick-dollar-brace-x produces an empty string before the interpolation and an empty string after it, so two interpolations yield three string segments.

What is the difference between the cooked and raw strings in a tag function?

The cooked array, accessed as strings, interprets escape sequences: backslash-n becomes an actual newline and backslash-t becomes a tab. The raw array, accessed as strings.raw, preserves the literal source characters, so backslash-n stays as the two characters backslash and n. The difference is load-bearing when a DSL processes text that uses backslashes structurally, such as regex patterns, LaTeX markup, or Windows file paths, where cooking would corrupt the input.

Do tagged template literals work in TypeScript without extra setup?

Yes. The tag function signature is function tag(strings: TemplateStringsArray, ...values: unknown[]): T, where TemplateStringsArray is defined in TypeScript's standard library file lib.es5.d.ts and requires no import. It is typed as a ReadonlyArray of string with an additional readonly raw member. The readonly modeling is type-level only; the runtime freezing of the template object is a separate ECMAScript behavior, not something the TypeScript type enforces.

Can a tagged template tag function return something other than a string?

Yes. A tag function is an ordinary function and may return any value the author chooses. styled-components returns a React component, graphql-tag returns a DocumentNode AST, and lit-html returns a TemplateResult. A safe-SQL tag can return a text and values object compatible with node-postgres. Plain template literals always coerce to a string, but a tag intercepts the segments and values before coercion, so the return type is entirely up to the implementation.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.