12k
All articles

Tagged Template Literals: Construindo Mini-DSLs em JavaScript

Tagged template literals em JavaScript: strings cooked e raw, cache WeakMap, HTML seguro, SQL parametrizado e mini-DSLs.

OpenReplay Team
OpenReplay Team
Tagged Template Literals: Construindo Mini-DSLs em JavaScript

Um tagged template literal é uma chamada de função cujos argumentos provêm de um template literal: o runtime divide o literal em cada fronteira ${...}, entrega os segmentos de string estáticos e as interpolações avaliadas a uma função que você nomeia, e permite que essa função retorne o que quiser. Essa última cláusula é o ponto central. Quando você escreve styled.div`...` ou gql`...`, está chamando uma função que recebe as partes do template e retorna algo que não é uma string — um componente React, um documento GraphQL parseado. Este artigo explica a mecânica da chamada com precisão e, em seguida, a utiliza para construir quatro mini-DSLs funcionais, incluindo uma tag SQL parametrizada que resiste a injeção baseada em valores por construção.

Você já usou essa sintaxe. O que se segue é como ela funciona e como criar a sua própria.

Principais Conclusões

  • Um tagged template tag`...` é açúcar sintático para tag(strings, ...values), onde strings é um array dos segmentos estáticos e ...values são as expressões interpoladas avaliadas da esquerda para a direita.
  • O array strings sempre tem exatamente um elemento a mais do que valuesstrings.length === values.length + 1 — porque um template literal sempre começa e termina com um segmento de string, mesmo que seja vazio.
  • Funções tag não são obrigadas a retornar strings: styled-components retorna um componente React, graphql-tag retorna uma AST DocumentNode, e lit-html retorna um TemplateResult.
  • Como o mesmo objeto de template congelado é reutilizado a cada avaliação de um tagged template específico, um WeakMap indexado pelo array strings pode armazenar em cache o trabalho de parsing daquele literal.
  • Uma tag SQL segura previne injeção SQL baseada em valores ao transformar interpolações em parâmetros de consulta em vez de concatená-las no texto SQL — desde que sempre parametrize valores e nunca insira SQL bruto ou identificadores diretamente.

A Assinatura da Chamada: tag`...` é Açúcar Sintático para tag(strings, ...values)

Um tagged template é açúcar sintático para uma chamada de função comum. A expressão tag`Hi, ${name}!` é equivalente a chamar tag com os segmentos estáticos como primeiro argumento e cada valor interpolado como argumento subsequente. As duas formas abaixo são aproximadamente equivalentes para ilustrar a assinatura da chamada:

const name = "Zach";

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

// O equivalente desaçucarado
tag(["Hi, ", "!"], name);

O primeiro parâmetro é um array dos segmentos de string estáticos, divididos em cada ${...}. Os argumentos restantes são os valores interpolados, que você coleta com um parâmetro rest. (A documentação de tagged templates do MDN descreve esse formato de chamada.)

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

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

As interpolações são avaliadas da esquerda para a direita no momento da chamada e passadas como seus valores reais — não como strings. Uma tag recebe um objeto de função, um array ou um objeto exatamente como escrito:

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

tag`${() => 42} and ${[1, 2, 3]}`;
// [ [Function], [1, 2, 3] ] — valores reais, não strings

Essa é a primeira diferença prática em relação a um template literal simples, que sempre converte interpolações para strings e sempre retorna uma string. Uma tag intercepta as partes antes que qualquer conversão ocorra.

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

O array strings sempre tem exatamente um elemento a mais do que o array valuesstrings.length === values.length + 1 — porque um template literal sempre começa e termina com um segmento de string, mesmo que esse segmento seja uma string vazia. Um literal com três interpolações tem quatro segmentos de string; um literal que começa com ${x} simplesmente tem uma string vazia como seu primeiro segmento:

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

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

Esse invariante é o motivo pelo qual reduzir sobre strings é a forma mais limpa de intercalar segmentos e valores: percorra as strings e acrescente values[i] após cada uma, exceto a última.

Cooked vs. Raw: Quando a Diferença é Fundamental

O array strings contém a interpretação cooked (processada), onde \n é uma nova linha e \t é uma tabulação. A propriedade strings.raw contém os caracteres de origem raw (brutos), onde \n é a sequência de dois caracteres barra-invertida-n. Essa distinção importa sempre que sua DSL processa texto que usa barras invertidas estruturalmente — padrões regex, marcação LaTeX ou caminhos de arquivo do Windows.

function showBoth(strings) {
  console.log(strings[0]);     // cooked: uma nova linha real
  console.log(strings.raw[0]); // raw: os caracteres \ e n
}

showBoth`line\nbreak`;
// cooked → "line", depois "break" na linha seguinte
// raw    → "line\nbreak"

O JavaScript inclui exatamente uma tag nativa para esse propósito: String.raw. Ela retorna a interpretação raw do literal, de modo que as sequências de escape sobrevivem como caracteres literais. (A referência do String.raw no MDN o documenta como a única tag de template nativa; ela se comporta como uma tag de identidade apenas quando o literal não contém sequências 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 — sem necessidade de escape duplo

Sem String.raw, o código-fonte da regex precisaria de \\d{3}-\\d{4} para sobreviver ao processamento. Qualquer tag que você escreva pode oferecer o mesmo comportamento lendo de strings.raw em vez de strings.

Funções Tag Podem Retornar Qualquer Coisa

Uma função tag é apenas uma função, e seu valor de retorno é o que o autor decidir. Esse é o único insight que transforma tagged templates de uma curiosidade de formatação de strings em uma ferramenta para construir linguagens específicas de domínio. Os segmentos estáticos e os valores são matéria-prima; a tag decide o que construir a partir deles. styled-components retorna um componente React, graphql-tag retorna uma AST DocumentNode, e lit-html retorna um TemplateResult.

As quatro DSLs abaixo evoluem de saída de string para objetos de consulta parametrizados, demonstrando tipos de retorno que nenhum template literal simples pode produzir.

Uma Tag de Escape HTML (Prevenindo XSS)

Uma tag de escape HTML envolve cada valor interpolado em codificação de entidades HTML antes que ele chegue ao DOM, de modo que a entrada do usuário não possa escapar de seu contexto de texto. O bug que ela previne: interpolar entrada não confiável diretamente em markup é um vetor de XSS armazenado ou refletido — um payload no valor é executado quando a string é inserida no DOM:

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

Uma tag safeHtml intercepta cada interpolação e a escapa antes que chegue à saída, enquanto deixa os segmentos estáticos — que o desenvolvedor escreveu, não o usuário — intocados:

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

A assimetria é o ponto central: segmentos estáticos são confiáveis (você os escreveu), interpolações não são (podem carregar entrada do usuário), e a tag é o único lugar onde essa distinção é aplicada.

Uma Tag SQL Segura (Consultas Parametrizadas por Construção)

Uma tag SQL segura previne injeção SQL baseada em valores ao transformar interpolações em parâmetros de consulta em vez de concatená-las no texto SQL. A versão com concatenação de strings de uma consulta é o bug clássico de injeção SQL — concatenar um valor no texto da consulta permite que esse valor altere a estrutura da consulta:

// QUEBRADO — não use
const id = "1 OR 1=1";
const query = `SELECT * FROM users WHERE id = ${id}`;
// "SELECT * FROM users WHERE id = 1 OR 1=1" → retorna todas as linhas

Uma tag SQL segura previne injeção SQL baseada em valores ao transformar interpolações em parâmetros de consulta em vez de concatená-las no texto SQL — desde que a tag sempre parametrize valores interpolados e nunca insira SQL bruto ou identificadores na consulta. A tag retorna um objeto { text, values }: o campo text contém placeholders ($1, $2, …), e values contém os dados reais, mantidos separados da estrutura da 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"]
// }

Esse formato { text, values } é exatamente o que o node-postgres aceita como consulta parametrizada: client.query(query) passa o texto da consulta e os valores dos parâmetros separadamente ao PostgreSQL, e o banco de dados nunca interpreta o valor do parâmetro como sintaxe SQL. A string maliciosa "1 OR 1=1" é tratada como um único valor de parâmetro, não como sintaxe SQL, portanto não pode se tornar parte da estrutura da consulta.

O motivo pelo qual isso é mais defensável do que a versão com concatenação de strings é estrutural: com a tag, um valor interpolado só pode se tornar um parâmetro. Não existe sintaxe na forma com tag que insira um valor no texto da consulta. A segurança se aplica a valores; ela não se estende a nomes dinâmicos de tabelas ou colunas, que são identificadores e devem ser validados separadamente em vez de interpolados.

Uma Tag i18n com Cache de Identidade via WeakMap

Como o mesmo objeto de template congelado é reutilizado a cada avaliação de um tagged template específico — um comportamento definido na especificação ECMAScript — um WeakMap indexado pelo array strings pode armazenar em cache o trabalho de parsing daquele literal: faça o parse uma vez e reutilize o resultado em avaliações subsequentes. Esse é o truque de identidade que permite que bibliotecas baseadas em templates evitem re-parsear o mesmo template a cada renderização.

A reutilização é observável. O mesmo array strings é referencialmente idêntico entre chamadas ao mesmo 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 — mesmo objeto de array congelado

Você pode verificar isso em qualquer console de navegador ou REPL do Node.js; é a reutilização do objeto de template definida pela especificação, não um detalhe de implementação. (A semântica de avaliação de templates da especificação ECMAScript define que o mesmo objeto de template é retornado para um dado template literal.)

Um WeakMap indexado em strings transforma isso em um cache. Aqui, uma tag i18n pré-computa a string de formato uma vez por literal e a reutiliza:

const cache = new WeakMap();

function t(strings, ...values) {
  let key = cache.get(strings);
  if (key === undefined) {
    // trabalho custoso feito uma vez por literal único
    key = strings.join("{}");
    cache.set(strings, key);
  }
  return lookupTranslation(key, values);
}

Um WeakMap mantém suas chaves de forma fraca, portanto as entradas do cache são elegíveis para coleta assim que o objeto de template não for mais referenciado — sem necessidade de evicção manual, sem colisões de chaves de string.

Uma Tag de Estilo CSS-in-JS

Uma tag de estilo pode retornar um objeto simples que o consumidor compõe em outro lugar, espelhando como styled-components e Emotion transformam um literal CSS em algo que um sistema de componentes consome. Em vez de retornar uma string, a tag parseia declarações em um objeto chave-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" }

Esse é o formato da coisa real. Em styled-components, styled.div`...` retorna um componente React; a documentação do styled-components cobre a API completa. A mecânica é idêntica ao exemplo acima — uma função tag consumindo um literal CSS e retornando um valor não-string que o framework sabe como utilizar.

Bibliotecas do Mundo Real Construídas com Tagged Templates

As bibliotecas abaixo usam tagged templates como sua API pública. Cada uma taga uma linguagem diferente e retorna um tipo diferente, o que é a demonstração mais clara de que o valor de retorno é uma escolha do autor.

BibliotecaO que você tagaO que ela retornaCaso de uso
styled-componentsCSSUm componente ReactCSS-in-JS com escopo de componente
EmotionCSSUm nome de classe / componente estilizadoCSS-in-JS com APIs de objeto e tagged template
lit-htmlHTMLUm TemplateResultRenderização eficiente de DOM
graphql-tagGraphQLUma AST DocumentNodeParsing de consultas para clientes GraphQL
sql-template-stringsSQLUm objeto de prepared statementSQL parametrizado (nota: não está mais em manutenção ativa)
common-tagsTextoStrings formatadasFunções tag reutilizáveis para template literals em ES2015+

A Assinatura TypeScript para uma Função Tag

A assinatura TypeScript para uma função tag é function tag(strings: TemplateStringsArray, ...values: unknown[]): T, onde TemplateStringsArray é tipado como um ReadonlyArray<string> com um membro adicional readonly raw: readonly string[]. O tipo está embutido na biblioteca padrão do TypeScript e não requer importação.

// Formato genérico de uma função tag
type Tag<T> = (
  strings: TemplateStringsArray,
  ...values: unknown[]
) => T;

// Aplicado à tag safe-sql:
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 é definido em lib.es5.d.ts e está disponível desde as primeiras versões do TypeScript. (As notas de versão do TypeScript 1.5 usam TemplateStringsArray em seu exemplo de tagged template.) Note que a modelagem readonly aqui é uma descrição em nível de tipo: o congelamento em tempo de execução do objeto de template é um comportamento ECMAScript separado, não algo que o tipo TypeScript causa.

Quando Não Usar Tagged Templates

Se sua DSL requer uma gramática real — precedência de operadores, lookahead ou estruturas recursivas — tagged templates são a ferramenta errada. A função tag recebe uma lista plana de segmentos de string e valores avaliados, não uma árvore de parse, portanto qualquer coisa que precise de mais do que avaliação da esquerda para a direita de interpolações pertence a um combinador de parsers ou gramática PEG. Recorra a um parser dedicado como nearley ou Peggy quando você se deparar com uma linguagem real.

Três sinais concretos de que você superou os tagged templates:

  1. Você precisa de uma gramática. Precedência, escopos aninhados ou backtracking não podem ser expressos como uma divisão plana de strings e valores. Use um parser.
  2. A ordem de avaliação além da esquerda para a direita importa. As interpolações são avaliadas da esquerda para a direita no momento da chamada, antes que a tag seja executada. Uma tag não pode adiar ou reordenar avaliações — ela só vê os resultados.
  3. Você está dentro de um loop crítico de desempenho. As expressões interpoladas são avaliadas a cada chamada, mesmo que o mesmo objeto de template seja reutilizado, portanto uma interpolação custosa dentro de um loop de renderização apertado paga seu custo a cada iteração. O array strings é cacheado por identidade; os values não são.

Para tudo entre interpolação simples e uma linguagem completa — escape, parametrização, indexação de cache, construção de um componente estilizado — tagged templates são a ferramenta de tamanho certo.

Comece com a tag safe-sql. Ela é pequena o suficiente para ser lida de uma vez, retorna um objeto { text, values } compatível com node-postgres e demonstra a propriedade que torna os tagged templates uma ferramenta valiosa: um valor que atravessa uma fronteira ${...} pode ser forçado a um papel seguro que a concatenação de strings nunca pode garantir.

Perguntas Frequentes

Por que strings.length é sempre um a mais do que values.length em um tagged template?

Um template literal sempre começa e termina com um segmento de string, mesmo quando esse segmento é vazio, de modo que os segmentos estáticos delimitam cada interpolação em ambos os lados. Com n interpolações há n mais 1 segmentos de string, gerando o invariante strings.length === values.length + 1. Um literal como tag-backtick-cifrão-chave-x produz uma string vazia antes da interpolação e uma string vazia depois dela, portanto duas interpolações geram três segmentos de string.

Qual é a diferença entre as strings cooked e raw em uma função tag?

O array cooked, acessado como strings, interpreta sequências de escape: barra-invertida-n se torna uma nova linha real e barra-invertida-t se torna uma tabulação. O array raw, acessado como strings.raw, preserva os caracteres de origem literais, portanto barra-invertida-n permanece como os dois caracteres barra-invertida e n. A diferença é fundamental quando uma DSL processa texto que usa barras invertidas estruturalmente, como padrões regex, marcação LaTeX ou caminhos de arquivo do Windows, onde o processamento corromperia a entrada.

Tagged template literals funcionam no TypeScript sem configuração adicional?

Sim. A assinatura da função tag é function tag(strings: TemplateStringsArray, ...values: unknown[]): T, onde TemplateStringsArray é definido no arquivo da biblioteca padrão do TypeScript lib.es5.d.ts e não requer importação. É tipado como um ReadonlyArray de string com um membro readonly raw adicional. A modelagem readonly é apenas em nível de tipo; o congelamento em tempo de execução do objeto de template é um comportamento ECMAScript separado, não algo que o tipo TypeScript aplica.

Uma função tag de tagged template pode retornar algo diferente de uma string?

Sim. Uma função tag é uma função comum e pode retornar qualquer valor que o autor escolher. styled-components retorna um componente React, graphql-tag retorna uma AST DocumentNode, e lit-html retorna um TemplateResult. Uma tag SQL segura pode retornar um objeto com text e values compatível com node-postgres. Template literals simples sempre convertem para string, mas uma tag intercepta os segmentos e valores antes da conversão, portanto o tipo de retorno fica inteiramente a cargo da implementação.

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.