TypeScript · i18n
Vérifier les clés et les placeholders de traduction avec TypeScript
Par Christophe Jean
Si vous maintenez une application TypeScript en plusieurs langues, vous avez peut-être déjà livré une traduction qui affiche « Portrait de {name} » au lieu d’un nom. Une clé mal écrite, un paramètre oublié ou un {nom} à la place de {name} ne fait rien planter : l’erreur arrive simplement à l’écran. Je vous montre ici comment déclarer une fois les clés et les paramètres de vos messages pour que TypeScript repère ces fautes à la compilation, dans les appels comme dans chaque dictionnaire.
Les exemples viennent du CV de cjean.fr. Ils utilisent i18n-tiny, une petite bibliothèque de traduction sans dépendance que j’ai écrite pour ce site.
Déclarer le contrat
Le CV contient des messages comme « Portrait de {name} » ou « Dernière mise à jour le {date} ». {name} et {date} sont des placeholders : des emplacements que la bibliothèque remplace par une valeur au moment de l’affichage.
Je commence par décrire, pour chaque clé, les paramètres attendus. Voici une version réduite de cette spécification :
import { createTypedTranslator } from "@cjean-fr/i18n-tiny";
type Spec = {
portrait_alt: readonly ["name"];
last_modified: readonly ["date"];
work_experience: readonly [];
};
const t = createTypedTranslator<Spec>()(
{
portrait_alt: "Portrait de {name}",
last_modified: "Dernière mise à jour le {date}",
work_experience: "Expériences professionnelles",
},
{ locale: "fr" },
);
t("portrait_alt", { name: "Christophe Jean" });
// Portrait de Christophe Jean
t("work_experience");
// Expériences professionnelles
readonly ["name"] veut dire : ce message attend un paramètre name. Un tuple vide décrit un message sans paramètre.
Le double appel createTypedTranslator<Spec>()(…) peut surprendre. TypeScript ne sait pas fixer un paramètre de type à la main et laisser inférer les autres dans le même appel. Le premier appel fixe donc Spec ; le second laisse TypeScript lire le texte exact de chaque message.
La spécification ne dit rien de la formulation. Elle décrit seulement ce que le code doit fournir à chaque message, quelle que soit la langue.
Ce que TypeScript refuse
Ces trois appels ne compilent pas :
// Clé inconnue
t("portrait");
// Paramètre requis absent
t("portrait_alt");
// Mauvais nom de paramètre
t("portrait_alt", { nom: "Christophe Jean" });
Les messages eux-mêmes sont vérifiés. Si j’écris "Portrait de {nom}" pour la clé portrait_alt, l’erreur de TypeScript contient :
error: "Invalid placeholder"; expected: "name"; found: "nom"
Et si j’écris "Dernière mise à jour" sans {date} :
error: "Missing placeholder"; expected: "date"; missing: "date"
Avec un simple Record<string, string>, aucune de ces erreurs n’apparaît : ce type dit qu’on a des textes, pas ce que chaque texte attend.
Une spécification pour toutes les langues
Le CV existe en français et en anglais. Les deux dictionnaires partagent la même spécification, et chacun est vérifié à la création de son traducteur :
const en = createTypedTranslator<Spec>()(
{
portrait_alt: "Portrait of {name}",
last_modified: "Last updated on {date}",
work_experience: "Work Experience",
},
{ locale: "en" },
);
en("portrait_alt", { name: "Christophe Jean" });
// Portrait of Christophe Jean
Changer de langue ne change pas les arguments à passer. Et si j’ajoute une clé à la spécification, chaque dictionnaire doit la fournir, sinon la compilation échoue.
Le choix de la langue reste dans l’application : sur le site, un module sélectionne le traducteur français ou anglais. La bibliothèque se contente de fournir la fonction t.
Les limites de la vérification
Tout repose sur le texte exact des messages. Un message dont TypeScript ne connaît que le type string, par exemple parce qu’il est chargé depuis une API, ne peut pas être analysé à la compilation. Pour ces traductions-là, il faut une validation à l’exécution.
Une assertion de type (as) ou un any contourne aussi le contrat, comme partout ailleurs en TypeScript.
La bibliothèque propose InferSpec, qui déduit la spécification d’un dictionnaire existant. C’est pratique pour migrer, mais si ce dictionnaire oublie un placeholder, la spécification déduite l’oublie aussi. Une spécification écrite à la main décrit ce que vous voulez, pas ce que le premier dictionnaire contient.
Ce que fait l’interpolateur, et ce qu’il ne fait pas
L’interpolateur par défaut remplace {name} par sa valeur, et rien de plus. Si un paramètre manque à l’exécution, le placeholder reste tel quel dans le texte ; aucune exception n’est levée.
Il ne gère pas la syntaxe ICU, ce format qui décrit par exemple les pluriels avec {count, plural, one {…} other {…}}. Si vous en avez besoin, vous pouvez fournir votre propre interpolateur et confier ce travail à une bibliothèque ICU. Les types continuent de vérifier les paramètres ; c’est l’interpolateur qui décide comment les afficher.
i18n-tiny n’échappe pas non plus les valeurs pour le HTML. Dans le CV, c’est Vincle, le moteur qui génère les pages HTML du site à partir de composants JSX, qui échappe les valeurs insérées. Si vous utilisez la bibliothèque ailleurs, l’échappement est à votre charge.
Est-ce que ça vaut le coût ?
Pour le CV, oui. Les messages sont peu nombreux et les paramètres simples, et une déclaration de quelques lignes suffit pour qu’une faute de frappe dans une traduction casse la compilation plutôt que la page. En échange, il faut maintenir cette spécification à côté des dictionnaires.
Pour un projet avec des pluriels complexes, des catalogues chargés à la demande ou des traducteurs qui travaillent hors du code, la vérification des types ne suffit pas : il vous faudra aussi un moteur de formatage et un vrai workflow de traduction.
Si vous voulez essayer, le README d’i18n-tiny détaille l’API et ses limites, et la bibliothèque est disponible sur npm.