jsguides

JavaScript Tagged Template Literals: How Tag Functions Work

JavaScript tagged templates let you process template literals through a custom function instead of getting a plain string. Introduced in ES6, this feature gives you complete control over how template expressions are combined and formatted before they become the final output.

How tagged templates work

A tagged template is simply a template literal preceded by a function name. When JavaScript encounters this, it calls your function with the template’s string parts and expressions as arguments.

const name = "Alice";
const score = 95;

function highlight(strings, ...values) {
  // strings is an array of the literal string segments
  // values is an array of the evaluated expressions
  console.log(strings);   // ['Hello ', ', you scored ', '!']
  console.log(values);   // ['Alice', 95]
  
  return "processed";
}

highlight`Hello ${name}, you scored ${score}!`;

The first argument — conventionally called strings — is an array containing all the literal parts of the template. The remaining arguments are the values from each ${expression}. The spread operator (...values) collects them into an array, which is the common pattern you’ll see.

Your function then returns whatever you want. JavaScript uses that return value as the result of the entire expression.

Building a real tag function

Let’s build something actually useful. Here’s a tag that highlights sensitive data:

function redact(strings, ...values) {
  let result = '';
  strings.forEach((string, i) => {
    const value = values[i];
    const processed = typeof value === 'string' && value.length > 3
      ? '█'.repeat(value.length)
      : value;
    result += string + (processed ?? '');
  });
  return result;
}

const user = "secretpassword123";
const ip = "192.168.1.1";

const logMessage = redact`User: ${user} logged in from IP: ${ip}`;
console.log(logMessage);
// User: █████████████ logged in from IP: ███████████

This example shows the key insight: you get full control over how each piece gets processed and combined. You could filter, sanitize, translate, format, or return a completely different data structure.

The strings array in detail

The strings array always has one more element than the number of expressions. This is because it captures the text before the first ${, between each ${}, and after the last ${}.

const a = 1, b = 2;

function debug(strings, ...values) {
  console.log('Number of strings:', strings.length);  // 3
  console.log('Number of values:', values.length);   // 2
  console.log('strings:', strings);   // ['', ' + ', ' = ', '']
  console.log('values:', values);      // [1, 2]
  return strings[0] + values[0] + strings[1] + values[1] + strings[2];
}

debug`${a} + ${b} = ${a + b}`;

The last element of strings is always an empty string if your template ends with an expression. This is easy to forget and can cause subtle bugs.

Raw strings access

Each string in the array also has a .raw property that gives you the uninterpreted text, useful when you need to handle escape sequences yourself:

function rawDemo(strings, ...values) {
  console.log(strings[0]);     // "line1\nline2"
  console.log(strings.raw[0]); // "line1\\nline2"
}

rawDemo`line1\nline2`;

The .raw version preserves the backslash-n as two characters instead of converting it to an actual newline. This distinction matters when you are writing a tag that needs to process source code or file paths. If the tag interprets escape sequences before your logic runs, you lose the ability to see what the author actually typed. By accessing strings.raw, you handle the raw character sequence and can decide later whether escape processing makes sense for your use case. Template engines and CSS-in-JS libraries depend on this raw access to preserve developer intent.

Practical use cases

Internationalization

Tag functions are popular for i18n libraries because they let you separate the template structure from the translated strings:

function t(strings, ...values) {
  const key = strings.join('${}');
  const translation = translations[key] || key;
  
  return translation.replace(/\$\{\}/g, () => values.shift());
}

const translations = {
  'Hello ${}, welcome to ${}': 'Bienvenido {}, a {}'
};

const username = 'Maria';
const platform = 'our app';

t`Hello ${username}, welcome to ${platform}`;
// Returns: "Bienvenido Maria, a our app"

SQL query builders

The translation example shows one direction: producing output for humans. Query builders take the same pattern toward a different goal: the output must be safe for a database engine. When user-provided values get concatenated into SQL strings without escaping, attackers can inject commands that read, modify, or delete data. Tagged templates let you build queries that always treat values as data, never as code.

Preventing SQL injection becomes easier when you can intercept and sanitize values:

function sql(strings, ...values) {
  const params = values.map(val => {
    if (typeof val === 'string') {
      return `'${val.replace(/'/g, "''")}'`;
    }
    return val;
  });
  
  return {
    text: strings.reduce((q, s, i) => q + s + (params[i] ?? ''), ''),
    params: values
  };
}

const userId = 42;
const query = sql`SELECT * FROM users WHERE id = ${userId}`;
console.log(query.text);
// SELECT * FROM users WHERE id = 42

Common Mistakes

Tagged templates are deceptively simple, and a handful of edge cases catch developers off guard. The most frequent one involves the last element of the strings array; it behaves differently depending on whether the template ends with a literal or an expression. Understanding these quirks early saves hours of debugging.

Forgetting that the last string element can be empty trips up many developers:

// Bug: this will fail when the template ends with an expression
function broken(strings, ...values) {
  return strings[1]; // undefined if template is `${x}something`
}

// Fixed: always check if the value exists
function fixed(strings, ...values) {
  return strings[0] + (values[0] ?? '') + strings[1];
}

Another subtle issue involves evaluation order. JavaScript evaluates every expression inside ${} before calling your tag function, so the tag cannot prevent or skip evaluation. Any side effects in those expressions, such as logging calls, mutations, or network requests, always run regardless of what your tag does with the values. If you are building a DSL where some branches should not evaluate, move the branch logic inside the tag, not inside the template expressions.

Side effects in expressions will execute regardless of whether you use the value:

functionTracker`${console.log('side effect'), 42}`;
// Logs: "side effect"

When to use tagged templates

You don’t need tagged templates for simple string interpolation. The regular template literal syntax ${expression} works fine for most cases.

Reach for tagged templates when you need to:

  • Transform or validate values before they’re inserted
  • Build domain-specific languages (DSLs)
  • Implement internationalization or templating systems
  • Create libraries that benefit from a clean, readable syntax

The syntax takes some getting used to, but once it clicks, you’ll see opportunities to use it everywhere.

Designing a Tag API

The best tag functions are small and opinionated. They should make one kind of template safer, clearer, or easier to process. If a tag tries to do everything, the syntax becomes harder to learn and the implementation becomes harder to trust. A narrow purpose usually leads to a better API because callers can guess what it does without reading a long reference page.

It also helps to keep the return value predictable. Some tags return strings, while others return objects or structured data. Either can work, but the shape should stay consistent so callers do not need to branch on special cases every time they use the tag. Clear output makes the tag feel like a real language feature rather than a bag of helper logic.

Escapes and raw text

Raw access is especially helpful when the template contains characters that should not be interpreted yet. That includes backslashes, path separators, and content that must be preserved exactly as typed. When you use the raw strings, you are choosing to treat the template as text data first and formatting later. That can be the right choice for parsers and formatters.

The raw form also makes it easier to build custom syntax that looks natural in source code. If the tag is meant to parse a mini language, the raw input is often the cleanest way to see what the author actually wrote. Just remember that raw text and processed text serve different goals, so pick the one that matches the feature.

When to use a plain helper

Not every string formatting task needs a tag. If the operation is simple and the caller already has the values in hand, a normal function may be easier to read. Tagged templates shine when the string structure itself matters and the template syntax improves the call site. If the call site becomes harder to scan, the tag may be doing more work than the feature needs.

A good test is whether the tag adds a meaningful boundary between the template and the data. If it does, the syntax earns its place. If it does not, a normal helper is usually the cleaner option and will be easier to move, test, and reuse later.

Designing for Reuse

A tagged template becomes much more useful when callers can use it in several places without learning a new set of rules each time. That usually means keeping the accepted input shape steady and making the output easy to predict. Reuse is not just about saving code. It is about making the syntax feel dependable.

If you expect other people to use the tag, document the examples that matter most and keep the behavior narrow. A tag that tries to solve every formatting problem will be harder to adopt than one that solves one problem well. Small, clear tags tend to survive longer because they are easier to explain and easier to compare with plain string helpers.

Keep the contract narrow

The easiest tags to maintain are the ones that make a very small promise. If the tag always formats the same kind of template in the same way, callers do not have to guess at special cases or hidden branches. That keeps the API honest and makes the code easier to revisit later.

That narrow promise is what gives a tag its staying power. When the behavior is stable, the call sites stay readable and the helper does not need constant explanation.

See Also