12k
All articles

Tagged Template Literals: Construcción de Mini-DSLs en JavaScript

Tagged template literals en JavaScript explicados con cadenas cooked y raw, caché WeakMap, HTML seguro, SQL y mini-DSL.

OpenReplay Team
OpenReplay Team
Tagged Template Literals: Construcción de Mini-DSLs en JavaScript

Un tagged template literal es una llamada a función cuyos argumentos provienen de un template literal: en tiempo de ejecución, el motor divide el literal en cada límite ${...}, entrega los segmentos de cadena estáticos y las interpolaciones evaluadas a una función que tú nombras, y permite que esa función devuelva lo que desee. Esta última cláusula lo es todo. Cuando escribes styled.div`...` o gql`...`, estás llamando a una función que recibe las piezas del template y devuelve algo que no es una cadena de texto — un componente de React, un documento GraphQL parseado. Este artículo explica el mecanismo de llamada con precisión y luego lo utiliza para construir cuatro mini-DSLs funcionales, incluyendo un tag SQL parametrizado que previene la inyección basada en valores por construcción.

Ya has usado esta sintaxis. Lo que sigue es cómo funciona y cómo construir la tuya propia.

Puntos Clave

  • Un tagged template tag`...` es azúcar sintáctico para tag(strings, ...values), donde strings es un array de los segmentos estáticos y ...values son las expresiones interpoladas evaluadas de izquierda a derecha.
  • El array strings siempre tiene exactamente un elemento más que valuesstrings.length === values.length + 1 — porque un template literal siempre comienza y termina con un segmento de cadena, aunque esté vacío.
  • Las funciones tag no están obligadas a devolver cadenas de texto: styled-components devuelve un componente de React, graphql-tag devuelve un AST DocumentNode, y lit-html devuelve un TemplateResult.
  • Dado que el mismo objeto de template congelado se reutiliza en cada evaluación de un tagged template específico, un WeakMap indexado por el array strings puede cachear el trabajo de parseo de ese literal.
  • Un tag SQL seguro previene la inyección SQL basada en valores convirtiendo las interpolaciones en parámetros de consulta en lugar de concatenarlos en el texto SQL — siempre que parametrice los valores y nunca inserte SQL crudo o identificadores directamente.

La Firma de Llamada: tag`...` se Desazucara a tag(strings, ...values)

Un tagged template es azúcar sintáctico para una llamada a función ordinaria. La expresión tag`Hi, ${name}!` es equivalente a llamar a tag con los segmentos estáticos como primer argumento y cada valor interpolado como argumento subsiguiente. Las dos formas a continuación son aproximadamente equivalentes para ilustrar la firma de llamada:

const name = "Zach";

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

// El equivalente desazucarado
tag(["Hi, ", "!"], name);

El primer parámetro es un array de los segmentos de cadena estáticos, divididos en cada ${...}. Los argumentos restantes son los valores interpolados, que se recogen con un parámetro rest. (La documentación de tagged templates de MDN describe esta forma de llamada.)

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

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

Las interpolaciones se evalúan de izquierda a derecha en el momento de la llamada y se pasan como sus valores reales — no como cadenas de texto. Un tag recibe un objeto función, un array o un objeto exactamente como se escribió:

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

tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — valores reales, no cadenas

Esta es la primera diferencia práctica respecto a un template literal simple, que siempre convierte las interpolaciones a cadenas de texto y siempre devuelve una cadena. Un tag intercepta las piezas antes de que ocurra cualquier conversión.

El Invariante strings.length === values.length + 1

El array strings siempre tiene exactamente un elemento más que el array valuesstrings.length === values.length + 1 — porque un template literal siempre comienza y termina con un segmento de cadena, aunque ese segmento sea una cadena vacía. Un literal con tres interpolaciones tiene cuatro segmentos de cadena; un literal que comienza con ${x} simplemente tiene una cadena vacía como primer segmento:

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

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

Este invariante es la razón por la que reducir sobre strings es la forma más limpia de intercalar segmentos y valores: recorre las cadenas y agrega values[i] después de cada una excepto la última.

Cooked vs. Raw: Cuándo la Diferencia es Fundamental

El array strings contiene la interpretación cooked (procesada), donde \n es un salto de línea y \t es un tabulador. La propiedad strings.raw contiene los caracteres fuente raw (sin procesar), donde \n es la secuencia de dos caracteres barra-invertida-n. Esta distinción importa cuando tu DSL procesa texto que usa barras invertidas de forma estructural — patrones regex, marcado LaTeX o rutas de archivo en Windows.

function showBoth(strings) {
  console.log(strings[0]);     // cooked: un salto de línea real
  console.log(strings.raw[0]); // raw: los caracteres \ y n
}

showBoth`line\nbreak`;
// cooked → "line", luego "break" en la siguiente línea
// raw    → "line\nbreak"

JavaScript incluye exactamente un tag integrado para este propósito: String.raw. Devuelve la interpretación raw del literal, por lo que las secuencias de escape sobreviven como caracteres literales. (La referencia de String.raw en MDN lo documenta como el único tag de template integrado; se comporta como un tag de identidad solo cuando el literal no contiene secuencias de escape.)

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

const pattern = new RegExp(String.raw`\d{3}-\d{4}`);
pattern.test("555-1234"); // true — sin necesidad de doble escape

Sin String.raw, el fuente del regex necesitaría \\d{3}-\\d{4} para sobrevivir al procesamiento. Cualquier tag que escribas puede ofrecer el mismo comportamiento leyendo desde strings.raw en lugar de strings.

Las Funciones Tag Pueden Devolver Cualquier Cosa

Una función tag es simplemente una función, y su valor de retorno es lo que el autor decida. Esta es la única idea que transforma los tagged templates de una curiosidad para formatear cadenas en una herramienta para construir lenguajes de dominio específico. Los segmentos estáticos y los valores son materia prima; el tag decide qué construir con ellos. styled-components devuelve un componente de React, graphql-tag devuelve un AST DocumentNode, y lit-html devuelve un TemplateResult.

Los cuatro DSLs a continuación escalan desde salida de cadenas hasta objetos de consulta parametrizados, demostrando tipos de retorno que ningún template literal simple puede producir.

Un Tag de Escape HTML (Previniendo XSS)

Un tag de escape HTML envuelve cada valor interpolado en codificación de entidades HTML antes de que llegue al DOM, de modo que la entrada del usuario no pueda escapar de su contexto de texto. El error que previene: interpolar entrada no confiable directamente en el marcado es un vector de XSS almacenado o reflejado — un payload en el valor se ejecuta cuando la cadena se inserta en el DOM:

const userInput = '<img src="x" onerror="alert(1)">';  
const markup = `<div>${userInput}</div>`;  
// element.innerHTML = markup → el manejador onerror se ejecuta

Un tag safeHtml intercepta cada interpolación y la escapa antes de que llegue a la salida, mientras deja los segmentos estáticos — que el desarrollador escribió, no el usuario — intactos:

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>" — inerte

La asimetría es el punto clave: los segmentos estáticos son de confianza (tú los escribiste), las interpolaciones no lo son (pueden contener entrada del usuario), y el tag es el único lugar donde se aplica esa distinción.

Un Tag SQL Seguro (Consultas Parametrizadas por Construcción)

Un tag SQL seguro previene la inyección SQL basada en valores convirtiendo las interpolaciones en parámetros de consulta en lugar de concatenarlos en el texto SQL. La versión de concatenación de cadenas de una consulta es el error canónico de inyección SQL — concatenar un valor en el texto de la consulta permite que ese valor cambie la estructura de la consulta:

// INCORRECTO — no usar
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → devuelve todas las filas

Un tag SQL seguro previene la inyección SQL basada en valores convirtiendo las interpolaciones en parámetros de consulta en lugar de concatenarlos en el texto SQL — siempre que el tag parametrice siempre los valores interpolados y nunca inserte SQL crudo o identificadores directamente en la consulta. El tag devuelve un objeto { text, values }: el campo text contiene los marcadores de posición ($1, $2, …), y values contiene los datos reales, mantenidos separados de la estructura de la consulta:

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"]
// }

La forma { text, values } es exactamente lo que node-postgres acepta como consulta parametrizada: client.query(query) pasa el texto de la consulta y los valores de los parámetros por separado a PostgreSQL, y la base de datos nunca interpreta el valor del parámetro como sintaxis SQL. La cadena maliciosa "1 OR 1=1" se trata como un único valor de parámetro, no como sintaxis SQL, por lo que no puede convertirse en parte de la estructura de la consulta.

La razón por la que esto es más defendible que la versión de concatenación de cadenas es estructural: con el tag, un valor interpolado solo puede convertirse en un parámetro. No existe ninguna sintaxis en la forma con tag que inserte un valor directamente en el texto de la consulta. La seguridad aplica para valores; no se extiende a nombres dinámicos de tablas o columnas, que son identificadores y deben validarse por separado en lugar de interpolarse.

Un Tag i18n con Caché de Identidad mediante WeakMap

Dado que el mismo objeto de template congelado se reutiliza en cada evaluación de un tagged template específico — un comportamiento definido en la especificación ECMAScript — un WeakMap indexado por el array strings puede cachear el trabajo de parseo de ese literal: parsearlo una vez y reutilizar el resultado cacheado en evaluaciones posteriores. Este es el truco de identidad que permite a las bibliotecas basadas en templates evitar re-parsear el mismo template en cada renderizado.

La reutilización es observable. El mismo array strings es referencialmente idéntico entre llamadas al mismo 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 — mismo objeto array congelado

Puedes verificar esto en cualquier consola de navegador o REPL de Node.js; es la reutilización del objeto de template definida en la especificación, no una peculiaridad de implementación. (La semántica de evaluación de templates de la especificación ECMAScript define que el mismo objeto de template se devuelve para un template literal dado.)

Un WeakMap indexado por strings convierte eso en una caché. Aquí, un tag i18n pre-computa la cadena de formato una vez por literal y la reutiliza:

const cache = new WeakMap();

function t(strings, ...values) {
  let key = cache.get(strings);
  if (key === undefined) {
    // trabajo costoso realizado una vez por literal único
    key = strings.join("{}");
    cache.set(strings, key);
  }
  return lookupTranslation(key, values);
}

Un WeakMap mantiene sus claves de forma débil, por lo que las entradas de la caché son elegibles para recolección una vez que el objeto de template ya no está referenciado — sin necesidad de evicción manual ni colisiones de claves de cadena.

Un Tag de Estilos CSS-in-JS

Un tag de estilos puede devolver un objeto plano que el consumidor compone en otro lugar, reflejando cómo styled-components y Emotion convierten un literal CSS en algo que un sistema de componentes puede consumir. En lugar de devolver una cadena, el tag parsea las declaraciones en un objeto clave-valor:

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" }

Esta es la forma de la implementación real. En styled-components, styled.div`...` devuelve un componente de React; la documentación de styled-components cubre la API completa. El mecanismo es idéntico al ejemplo anterior — una función tag que consume un literal CSS y devuelve un valor que no es una cadena y que el framework sabe cómo utilizar.

Bibliotecas del Mundo Real Construidas sobre Tagged Templates

Las bibliotecas a continuación utilizan tagged templates como su API pública. Cada una etiqueta un lenguaje diferente y devuelve un tipo diferente, lo que es la demostración más clara de que el valor de retorno es una elección del autor.

BibliotecaQué se etiquetaQué devuelveCaso de uso
styled-componentsCSSUn componente de ReactCSS-in-JS con alcance por componente
EmotionCSSUn nombre de clase / componente con estilosCSS-in-JS con APIs de objeto y tagged template
lit-htmlHTMLUn TemplateResultRenderizado eficiente del DOM
graphql-tagGraphQLUn AST DocumentNodeParseo de consultas para clientes GraphQL
sql-template-stringsSQLUn objeto de prepared statementSQL parametrizado (nota: ya no se mantiene activamente)
common-tagsTextoCadenas formateadasFunciones tag reutilizables para template literals en ES2015+

La Firma TypeScript para una Función Tag

La firma TypeScript para una función tag es function tag(strings: TemplateStringsArray, ...values: unknown[]): T, donde TemplateStringsArray está tipado como un ReadonlyArray<string> con un miembro adicional readonly raw: readonly string[]. El tipo está integrado en la biblioteca estándar de TypeScript y no requiere importación.

// Forma genérica de una función tag
type Tag<T> = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => T;

// Aplicado al tag sql seguro:
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 está definido en lib.es5.d.ts y ha estado disponible desde las primeras versiones de TypeScript. (Las notas de lanzamiento de TypeScript 1.5 utilizan TemplateStringsArray en su ejemplo de tagged template.) Nótese que el modelado readonly aquí es una descripción a nivel de tipos: el congelamiento en tiempo de ejecución del objeto de template es un comportamiento separado de ECMAScript, no algo que el tipo de TypeScript cause.

Cuándo No Usar Tagged Templates

Si tu DSL requiere una gramática real — precedencia de operadores, lookahead o estructuras recursivas — los tagged templates son la herramienta equivocada. La función tag recibe una lista plana de segmentos de cadena y valores evaluados, no un árbol de parseo, por lo que cualquier cosa que necesite más que evaluación de interpolaciones de izquierda a derecha pertenece a un combinador de parsers o una gramática PEG. Recurre a un parser dedicado como nearley o Peggy cuando te enfrentes a un lenguaje real.

Tres señales concretas de que has superado los tagged templates:

  1. Necesitas una gramática. La precedencia, los ámbitos anidados o el backtracking no pueden expresarse como una división plana de cadenas y valores. Usa un parser.
  2. El orden de evaluación más allá de izquierda a derecha importa. Las interpolaciones se evalúan de izquierda a derecha en el momento de la llamada, antes de que el tag se ejecute. Un tag no puede diferirlas ni reordenarlas — solo ve los resultados.
  3. Estás dentro de un bucle crítico de rendimiento. Las expresiones interpoladas se evalúan en cada llamada aunque el mismo objeto de template se reutilice, por lo que una interpolación costosa dentro de un bucle de renderizado ajustado paga su costo en cada iteración. El array strings se cachea por identidad; los values no.

Para todo lo que está entre la interpolación simple y un lenguaje completo — escapar, parametrizar, indexar una caché, construir un componente con estilos — los tagged templates son la herramienta del tamaño adecuado.

Comienza con el tag sql seguro. Es lo suficientemente pequeño como para leerlo de una sola vez, devuelve un objeto { text, values } compatible con node-postgres, y demuestra la propiedad que hace que los tagged templates valgan la pena: un valor que cruza un límite ${...} puede forzarse a un rol seguro que la concatenación de cadenas nunca puede garantizar.

Preguntas Frecuentes

¿Por qué strings.length siempre es uno más que values.length en un tagged template?

Un template literal siempre comienza y termina con un segmento de cadena, incluso cuando ese segmento está vacío, por lo que los segmentos estáticos enmarcan cada interpolación por ambos lados. Con n interpolaciones hay n más 1 segmentos de cadena, dando el invariante strings.length === values.length + 1. Un literal como tag-backtick-dollar-brace-x produce una cadena vacía antes de la interpolación y una cadena vacía después, por lo que dos interpolaciones generan tres segmentos de cadena.

¿Cuál es la diferencia entre las cadenas cooked y raw en una función tag?

El array cooked, accedido como strings, interpreta las secuencias de escape: barra-invertida-n se convierte en un salto de línea real y barra-invertida-t en un tabulador. El array raw, accedido como strings.raw, preserva los caracteres fuente literales, por lo que barra-invertida-n permanece como los dos caracteres barra-invertida y n. La diferencia es fundamental cuando un DSL procesa texto que usa barras invertidas de forma estructural, como patrones regex, marcado LaTeX o rutas de archivo en Windows, donde el procesamiento corruiría la entrada.

¿Funcionan los tagged template literals en TypeScript sin configuración adicional?

Sí. La firma de la función tag es function tag(strings: TemplateStringsArray, ...values: unknown[]): T, donde TemplateStringsArray está definido en el archivo de la biblioteca estándar de TypeScript lib.es5.d.ts y no requiere importación. Está tipado como un ReadonlyArray de string con un miembro readonly raw adicional. El modelado readonly es solo a nivel de tipos; el congelamiento en tiempo de ejecución del objeto de template es un comportamiento separado de ECMAScript, no algo que el tipo de TypeScript aplique.

¿Puede una función tag de tagged template devolver algo distinto a una cadena de texto?

Sí. Una función tag es una función ordinaria y puede devolver cualquier valor que el autor elija. styled-components devuelve un componente de React, graphql-tag devuelve un AST DocumentNode, y lit-html devuelve un TemplateResult. Un tag SQL seguro puede devolver un objeto con text y values compatible con node-postgres. Los template literals simples siempre convierten a cadena de texto, pero un tag intercepta los segmentos y valores antes de la conversión, por lo que el tipo de retorno depende completamente de la implementación.

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.