Introduction

Il n’y a pas longtemps, un de mes collègues est venu me poser cette question :

« Ah tiens j’ai vu une syntaxe bizarre dans mon tuto sur Deno, je voulais te demander ce que c’était ? Ils préfixent une template string avec un nom de fonction, et je ne sais pas à quoi ça correspond dans le langage 🤔 »

Puis il me montre son écran :

1
await conn.queryObject`SELECT * FROM users WHERE id = ${userId}`

Ah, ça ! Et bien ça s’appelle un tagged template literal. Mes explications et mes exemples sur le sujet ont eu l’air de répondre à ses questions, alors je me suis dit que ce serait une bonne idée d’en faire profiter tout le monde.

C’est parti ! 💪

Note : Le MDN propose dans sa documentation des traductions françaises pour cette fonctionnalité, mais je préfère conserver les termes anglais pour la suite de cet article. Sachez toutefois pour votre culture qu’on parle respectivement de gabarits étiquetés et de littéraux de gabarits pour les termes tagged template et template literals.

Un petit rappel sur les template literals

Introduite avec la norme ES2015, cette fonctionnalité très attendue a enfin permis aux développeurs de construire des chaînes de caractères en y incorporant directement des expressions du langage.

Là où il fallait auparavant recourir à la concaténation, on dispose désormais d’une syntaxe plus lisible et moins verbeuse.

Avant :

1
const welcome = "Mon nom est " + name + ", j'ai " + age + " ans et aujourd'hui, je vais vous parler de " + topic

Après :

1
const welcome = `Mon nom est ${name}, j'ai ${age} ans et aujourd'hui, je vais vous parler de ${topic}`

Les template literals (ou template strings) sont délimités par des backticks ` (ou backquote).

Note : Sur les claviers de PC, le backtick s’écrit à l’aide de la combinaison de touches AltGr+7. Attention ! Il s’agit d’une « touche morte », il faudra presser la touche Espace à la suite pour l’obtenir à la place d’une combinaison avec une voyelle (à, è, ì, etc.) Ça paraît compliqué, mais l’habitude viens vite, rassurez-vous 😉

Chaînes de caractères multi-lignes

Autre fonctionnalité intéressante, les templates literals peuvent s’étaler sur plusieurs lignes, comme ceci :

1
2
3
4
const message = `Bienvenue 
chez moi, je m'appelle ${name},
je vous en prie, prenez place.`
// -> "Bienvenue\nchez moi, je m'appelle Fabien,\nje vous en prie, prenez place."

Bien entendu, le caractère de saut de ligne \n est ajouté automatiquement, mais on peut l’échapper comme n’importe quel autre caractère :

1
2
3
const message = `Bienvenue \
chez moi`
// -> "Bienvenue chez moi"

Interpolation d’expressions

On l’a dit, on peut maintenant appeler des expressions du langage à l’intérieur de la chaîne, via la syntaxe ${<expression>} :

1
const addition = (a, b) => `La somme ${a} + ${b} vaut ${a + b}`

Nom de variable, résultat d’une opération, appel de méthode, … tout est possible.

De plus, les expressions sont automatiquement converties en chaîne de caractère, via un appel au constructeur String.

Il faudra néanmoins faire attention à cette conversion avec les objets :

1
2
3
const personne = { age: 42, gender: 'man' }
console.log(`Le contenu de l'objet est ${o}`)
// -> Le contenu de l'objet est [object Object]

Il est possible dans ce cas de définir sur l’objet une surcharge de la méthode toString().

Un tagged template, c’est quoi ?

Il est possible de « taguer » un template literal à l’aide d’une tag function.

On peut utiliser cette fonction sur un template literal comme ceci :

1
myTag`Bonjour ${name}, vous pouvez me contacter au ${phone}. Bonne journée.`

Quelle différence avec une fonction normale me demanderez-vous ? Pourquoi ne pas écrire simplement :

1
myTag(`Bonjour ${name}, vous pouvez me contacter au ${phone}. Bonne journée.`)

Et bien contrairement à une fonction normale, celle-ci est appelée par JavaScript et reçoit les différents constituants de la chaîne et non la chaîne résultante :

1
2
3
4
5
function myTag(fragments, ...values) {
  // fragments = ["Bonjour ", ", vous pouvez me contacter au ", ". Bonne journée."]
  // values = ["Fabien", "06 00 00 00 00"]
  return ...
}

On voit ici qu’une tag function est appelée avec deux arguments :

  • un tableau de chaîne de caractères contenant les fragments du template literal, c’est-à-dire les parties statiques entourant les expressions
  • une liste d’arguments variables (varargs) contenant les valeurs des expressions, qui constituent les parties dynamiques du littéral

diagramme illustrant la séparation entre fragments et valeurs

Note : Le tableau values contient les valeurs avant leur conversion en chaîne, donc il est possible d’y retrouver des valeurs de tout type.

Le template literal n’est pas traité au moment où la tag function est appelée : c’est la valeur de retour de celle-ci qui détermine la chaîne résultante.

On pourrait très bien imaginer un tag privacy qui cache toutes les données passées dans la chaîne :

1
2
const message = privacy`Bonjour ${name}, vous pouvez me contacter au ${phone}. Bonne journée.`
// -> message = "Bonjour xxx, vous pouvez me contacter au xxx. Bonne journée."

D’ailleurs, il n’est absolument pas obligatoire de renvoyer une chaîne, on peut renvoyer ce que l’on veut.

À quoi ça sert ?

Les tags permettent de personnaliser la façon dont les littéraux sont interprétés.

On peut par exemple :

  • modifier la valeur d’une ou plusieurs expressions ou modifier les fragments avant de reconstruire la chaîne
  • retourner un objet résultant d’un traitement prenant en entrée les fragments et les valeurs séparément

Cela va s’avérer particulièrement utile et puissant, en particulier lorsque l’on fait du templating pour un autre langage au sein du code JavaScript, pour du HTML, du CSS, du SQL, etc.

Une tag function de base : String.raw

Toujours depuis ES2015, il existe un tag de base dans le langage : String.raw.

Celui-ci permet de définir des chaînes textuelles (aussi appelées verbatim strings), dans lesquelles les caractères de contrôle ne sont pas interprétés, et qui ne nécessitent donc aucun échappement.

C’est particulièrement utile pour les chemins d’accès sous Windows par exemple, car cette déclaration :

1
const filename = "C:\\Users\\machin\\Documents"

peut être remplacé par celle-ci :

1
const filename = String.raw"C:\Users\machin\Documents"

On peut aussi les utiliser pour éviter l’échappement de caractères spéciaux dans une RegExp créée dynamiquement à partir d’une chaîne.

Comment ça marche ?

Reconstruire une chaîne à partir des fragments et des valeurs

On a vu plus haut que la tag function reçoit à la fois les fragments et les valeurs des expressions contenus dans les template strings.

Dans la plupart des cas, on va vouloir reconstruire une chaîne en recombinant ces éléments, après y avoir apporté nos modifications.

On va créer ici le tag noopTag qui n’apporte pas de modifications et retourne le même résultat qu’un template literal normal.

1
2
3
function noopTag(fragments, values...) {
  return values.reduce((acc, value, i) => `${acc}${fragments[i]}${value}`, '') + fragments.slice(-1) 
}

Avant de rentrer dans le détail de cet algorithme, il faut noter que :

  • fragments compte toujours un élément de plus que values : logique puisque les fragments entourent les valeurs
1
2
3
tag`Je m'appelle ${prenom}, j'ai ${age} ans.`
// fragments : "Je m'appelle " |          | ", j'ai " |    | " ans."
//    values :                 | "Fabien" |           | 32 |
  • ce postulat est toujours vrai, même aux cas limites :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Valeur en fin de chaîne
tag`Je m'appelle ${prenom}`
// fragments : "Je m'appelle "  |          | ""
//   values  :                  | "Fabien" |

// Valeur en début de chaîne
tag`${nbarticles} articles trouvés`
// fragments : "" |    | " articles trouvés"
//    values :    | 13 |

// Une seule valeur sans fragment
tag`${nombre}`
// fragments : "" |    | ""
//    values :    | 42 |

// Aucune valeur
tag`Ceci est une phrase banal`
// fragments : "Ceci est une phrase banal"
//    values : []

On peut donc recombiner la chaîne comme suit :

  • On utilise reduce sur le tableau de valeurs, puis on utilise l’indice courant pour récupérer l’élément correspondant dans le tableau des fragments (on itère sur les 2 tableaux en parallèle)
  • On ajoute le dernier fragment en fin de chaîne
1
2
3
4
5
6
tag`Je m'appelle ${prenom}, j'ai ${age} ans.`
// fragments : ["Je m'appelle ", ", j'ai ", " ans."]
//    values : ["Fabien", 32]

// --> fragments[0] + values[0] + fragments[1] + values[1] + fragments[2]
// --> "Je m'appelle Fabien, j'ai 32 ans."

Si on est amené à reconstruire régulièrement des chaînes dans nos tags functions, on peut créer une fonction utilitaire dédiée :

1
2
3
4
5
6
7
function cook(fragments, values) {
  return values.reduce((acc, value, i) => `${acc}${fragments[i]}${value}`, '') + fragments.slice(-1)
}

function noopTag(fragments, values...) {
  return cook(fragments, values)
}

Manipulation des valeurs

Prenons un exemple simple avec la tag function highlight :

1
2
3
const text = highlight`
Bonjour, je m'appelle ${name}, je suis développeur logiciel
`

On veut mettre en évidence les valeurs injectées dans le template, avec une balise <strong> par exemple :

1
2
3
4
function highlight(fragments, ...values) {
  const newValues = values.map(value => `<strong>${value}</strong>`)
  return cook(fragments, newValues)
}

On applique la transformation sur nos valeurs, en entourant chaque valeur avec la balise, puis on utilise notre fonction cook définie précédemment pour reconstruire la chaîne. Cela nous donne dans notre chaîne message :

1
"Bonjour, je m'appelle <strong>Fabien</strong>, je suis développeur logiciel"

Rien ne nous empêche d’ailleurs d’appliquer une transformation sur les fragments statiques de la chaîne.

Rendre une tag function paramétrable

On peut vouloir fournir un ou plusieurs paramètres supplémentaires à une tag function, pour pouvoir modifier son comportement.

Comment s’y prendre étant donné la syntaxe si particulière de ce type de fonction ?

1
translate`Hello ${name}`

translate reste une fonction, donc comment peut-on la rendre paramétrable ? Et bien grâce à une fonction d’ordre supérieure !

1
2
3
4
5
6
7
function translate(lang) {
  // On retourne une tag function
  return (fragments, ...values) => {
    // Dans le corps du tag, on peut accéder au paramètre de la fonction d'ordre supérieur
    return translateString(lang, fragments, values)
  }
}

Et voilà le travail 🤩 :

1
translate('fr')`Hello ${name}`

La chaîne est taguée par la fonction que retourne l’appel à translate . Celle-ci est paramétrée grâce à l’argument du paramètre lang qui vaut ici "fr".

Cas d’usage

Requêtes préparées

Les requêtes préparées sont un mécanisme que l’on retrouve couramment lorsque l’on communique avec une base de données relationnelle. Elles permettent entre autres d’écarter les risques d’injection de code malveillant dans des requêtes SQL.

Voici un exemple d’exécution de requête préparée avec le driver pg (documentation ici):

1
2
3
4
const sql = 'INSERT INTO users(name, email) VALUES($1, $2)'
const values = ['Fabien', 'fabien@devmachine.fr']
 
await client.query(text, values)

On voit que notre requête est séparée en 2 composantes :

  • Le texte SQL de la requête, dans lequel les emplacements des valeurs à injecter sont balisés par des marqueurs de substitution ($1, $2)
  • Les valeurs que l’on veut injecter dans l’ordre à l’emplacement de ces marqueurs

C’est parfait ! Séparer et rassembler les parties statiques et dynamiques d’une chaîne, c’est le principal intérêt des tags functions :

1
prepareQuery`INSERT INTO users(name, email) VALUES(${name}, ${email})`

Grâce à cette tag function, on écrit la requête de façon plus naturelle, dans un seul template literal, mais on va quand même maintenir la séparation fragments/valeurs sous le capot.

Voici ce que pourrait donner son implémentation :

1
2
3
4
5
function prepareQuery(fragments, ...values) {  
  const placeholders = values.map((_, index) => `$${index + 1}`)  // (1) 
  const cooked = cook(fragments, placeholders)                    // (2)
  return client.query(cooked, values)                             // (3)
}

Détaillons les étapes de cette fonction :

Étape Explication Résultat
1 On créé le tableau placeholders à partir des valeurs ["$1", "$2"]
2 On reconstruit la chaîne en remplaçant les valeurs par les placeholders "INSERT INTO users(name, email) VALUES($1, $2)"
3 On fait appel à query() comme dans l’exemple plus haut, en lui passant la chaîne reconstituée et les valeurs originelles résultat de l’exécution de la requête

J’ai décrit une implémentation naïve pour démontrer le principe, mais sachez que le driver Deno pour PostgreSQL propose des tags functions similaires :

1
2
3
4
5
6
7
8
const conn = await pool.connect()
const result = await conn.queryObject`SELECT * FROM users WHERE id = ${userId}`
await conn.queryArray`
  UPDATE posts SET 
    last_update_date = ${updateDate},
    message = ${message}
  WHERE user_id = ${userId}
`

Pas mal, non ? 😎

Internationalisation (i18n)

Un autre exemple est le support de l’internationalisation (i18n) dans nos chaînes de caractères.

L’approche visant à utiliser des clés de traductions peut parfois se révéler fastidieuse. On peut proposer une alternative en utilisant des phrases dans une langue de référence comme clé de traduction, puis définir leurs traductions dans d’autres langues.

1
2
3
4
5
6
7
8
9
10
11
12
const lang = 'fr'

// Définition des traductions
const translations = {
  "Hello {0}, how are you?": {
    fr: "Bonjour {0}, comment allez-vous ?",
    es: "Hola {0}, ¿qué tal?",
  }
}

// ... plus loin dans le code
console.log(translate("Hello {0}, how are you?", "Fabien"))

Bien sûr, c’est un exemple simpliste, mais on voit que l’utilisation de la fonction translate n’est pas idéale, et nous oblige à séparer les parties statiques et dynamiques de la chaîne.

Ça tombe bien, je vous ai expliqué comment fonctionnent les tags functions, on va pouvoir s’en servir ! Voici comment :

1
2
const name = 'Fabien'
console.log(translate`Hello ${name}, how are you?`)

Avouez que c’est nettement plus sympa de l’écrire comme ça. 😍 Maintenant, voyons comment cela se matérialise sous le capot de notre tag translate :

1
2
3
4
5
6
7
function translate(fragments, values...) {
  const placeholders = values.map((_, index) => `{${index}}`)   // (1)
  const cooked = cook(fragments, placeholders)                  // (2)
  const translation = translations[cooked][lang]                // (3)
  const translatedFragments = translation.split(/{\d+}/)        // (4)
  return cook(translatedFragments, values)                      // (5)
}
Étape Explication Résultat
1 On créé le tableau placeholders à partir des valeurs ["{0}"]
2 On reconstruit la chaîne en remplaçant les valeurs par les placeholders "Hello {0}, how are you?"
3 On a notre clé de traduction, on va donc récupérer la traduction correspondante dans la langue courante "Bonjour {0}, comment allez-vous ?"
4 On refait le chemin inverse, on extrait les fragments de la chaîne traduite ["Bonjour ", ", comment allez-vous ?"]
5 On recombine les fragments de la traduction avec les valeurs à injecter pour obtenir la traduction finale "Bonjour Fabien, comment allez-vous ?"

D’ailleurs, si on veut rendre le tag paramétrable comme on l’a vu plus haut, on peut spécifier directement la langue ciblée :

1
2
3
4
5
6
7
8
function translate(lang) {
  return (fragments, values...) => {
    // ...
    // lang n'est plus une variable globale mais fait partie de la portée englobante
    const translation = translations[cooked][lang]
    // ...
  }
}

Ce qui nous permet de l’utiliser comme ceci :

1
2
3
4
translate('fr')`Hello ${name}, how are you?`
// -> "Bonjour Fabien, comment allez-vous ?"
translate('es')`Hello ${name}, how are you?`
// -> "Hola Fabien, ¿qué tal?"

Formatage de valeurs

On peut aussi imaginer des tags functions permettant de personnaliser le formatage des valeurs passées dans une chaîne.

1
2
3
const totalAmount = 3415.63
currency`Valeur totale du panier : ${totalAmount}`
// -> "Valeur totale du panier : 3 415,637 €"

Ici, on veut formater les valeurs monétaires grâce à notre tag function currency

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function formatCurrency(value, locale = 'fr-FR', currency = 'EUR') {
  return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(
    value,
  ),
}

function currency(fragments, ...values) {
  const formattedValues = values.map(value => {
    // Formatage personnalisé pour tous les valeurs de type nombres
    if (typeof value === 'number' && !Number.isNaN(value)) {
      return formatCurrency(value)
    }
    return value
  })
  return cook(fragments, formattedValues)
}

💡 Le saviez-vous ? ECMAScript propose une API d’internationalisation normalisée, l’API Intl, qui permet notamment de gérer les problématiques de formatage de nombres de manière standardisée et compatible avec les différents navigateurs et runtimes.

On pourrait améliorer ce tag en le rendant paramétrable pour spécifier des options de formatage, comme par exemple un identifiant de langue ou de monnaie.

Bibliothèques reposant sur les tags functions

  • La bibliothèque lit-html intégrée au framework Lit permet de définir des templates HTML de composants grâce à la tag function html.
1
2
3
4
5
6
7
const todos = ['Tâche 1','Tâche 2']
const todoListTemplate = html`
<ul>
  ${todos.map(todo => html`<li>${todo}</li>`)}
</ul>
`
render(todoListTemplate(todos), document.body)

🌎 Lien du projet : Github

  • La bibliothèque styled-component permet aux développeurs React de créer des composants et des éléments du DOM en leur attribuant directement du code CSS.
1
2
3
4
5
const Title = styled.h1`
  font-size: 1.5em;
  text-align: center;
`
render(<Title>Titre de la page</Title>)

🌎 Lien du projet : Site web

Conclusion

Dans cet article, on a vu ce que sont les tags functions et comment elles peuvent se combiner au template literal pour adresser certaines problématiques de templating.

On a d’abord replacé un peu le contexte en rappelant ce qu’étaient les templates literals en JavaScript. Puis on a expliqué l’utilité des tags functions et détaillé leur fonctionnement à l’aide de quelques exemples.

On a enfin présenté des cas d’usages dans lesquels cette fonctionnalité peut s’illustrer et comment celle-ci est mise en place dans certaines bibliothèques de fonctions JavaScript.

J’espère que j’ai pu éclairer vos lanternes sur ce sujet.

Je vous remercie de votre attention ! 🙏 😊

Bibliographie

J’adresse mes remerciements aux auteurs de ces articles et de ces bibliothèques qui m’ont inspirés pour la rédaction de cet article :