Un pluriel ICU MessageFormat place les variantes d’une phrase dans le message lui-même, par catégorie grammaticale (one, other…) et non par une condition if dans le code. Chaque langue applique ainsi ses propres règles : en français, zéro est au singulier, en anglais au pluriel. Dans un SaaS, le même format sert aussi à rendre les erreurs d’API, les e-mails et l’interface depuis un catalogue unique, à condition que le serveur renvoie des identifiants de message plutôt que des phrases.

Qu’est-ce qu’ICU MessageFormat ?

ICU MessageFormat est une syntaxe de messages définie par le projet ICU (International Components for Unicode). Un message est une chaîne qui contient des arguments entre accolades. Un argument peut être simple ({email}), typé ({total, number}, {deadline, date, long}) ou porter des branches (plural, select).

L’intérêt est de déplacer la grammaire du code vers le message. Le développeur écrit un seul appel avec des valeurs ; chaque traduction décide de la forme correcte dans sa langue.

Comment écrire un pluriel ICU ?

Un pluriel se déclare avec {variable, plural, ...} suivi de branches. Voici un message réel du catalogue du kit, en anglais puis en français :

{ "count": "{count, plural, =0 {No member} one {# member} other {# members}}" }
{ "count": "{count, plural, =0 {Aucun membre} one {# membre} other {# membres}}" }

Trois éléments de syntaxe sont à retenir :

  • =0 est une correspondance exacte : elle prime sur les catégories et permet une formulation dédiée (« Aucun membre ») ;
  • one, other sont des catégories CLDR, pas des nombres. Selon la langue, il en existe jusqu’à six : zero, one, two, few, many, other ;
  • # est remplacé par la valeur, formatée selon la locale.

La branche other est obligatoire. Le piège classique est de lire one comme « égal à 1 » : en français, la catégorie one couvre 0 et 1, alors qu’en anglais 0 tombe dans other. Sans la branche =0, le message afficherait « 0 membre » en français et « 0 members » en anglais, chacun correct dans sa langue.

Un pluriel peut contenir d’autres arguments. Le catalogue du kit en donne un exemple : "{shown} of {count, plural, one {# member} other {# members}}", où shown reste un argument simple.

Quand utiliser select ?

select choisit une branche selon une valeur textuelle, par exemple un rôle ou un genre grammatical :

{role, select, owner {Propriétaire de l'espace} admin {Administrateur} other {Membre}}

Comme pour plural, la branche other est obligatoire et sert de repli. Le catalogue actuel du kit n’utilise pas select : il porte surtout des pluriels et des arguments simples, et rend les valeurs techniques par un libellé dédié (voir plus bas).

Pourquoi le texte français est-il plus long, et que faire dans l’interface ?

Une traduction française utilise en général plus de caractères que la source anglaise : articles, prépositions, accords. Sur les catalogues du kit au 2 novembre 2026, une mesure des 1 288 messages présents dans les deux langues donne 46 022 caractères en français contre 40 606 en anglais (syntaxe ICU comprise), soit environ 13 % de plus ; 949 de ces messages sont plus longs en français. La proportion varie d’un message à l’autre : la moyenne ne garantit rien pour un libellé donné.

Les conséquences pour l’interface sont directes :

  • ne pas fixer la largeur d’un bouton, d’un onglet ou d’une colonne sur la longueur du texte anglais ;
  • laisser le texte passer à la ligne plutôt que de le couper, et réserver la troncature avec « … » aux données saisies par l’utilisateur (un nom d’espace), jamais à une phrase qui porte un sens ;
  • relire chaque écran dans la langue la plus longue livrée, pas seulement dans la langue de développement.

Comment intl-messageformat exécute-t-il ces messages ?

intl-messageformat (projet FormatJS) analyse un message ICU et le formate avec les API Intl du moteur JavaScript, dont Intl.PluralRules pour les catégories de pluriel. La fonction de traduction du kit se résume à ceci :

import { IntlMessageFormat } from "intl-messageformat";

const formatter = new IntlMessageFormat(message, locale);
const text = formatter.format({ count: 5 }); // "5 membres" en fr

Deux détails d’implémentation comptent en production. Les messages compilés sont mis en cache par couple langue-clé, car analyser l’ICU à chaque appel coûte cher sur un chemin chaud. Et un argument manquant ne lève pas d’exception : la fonction rend le message ICU brut, parce que ces messages servent aussi sur des chemins d’erreur qui ne doivent pas en créer une seconde.

Le kit utilise intl-messageformat directement. i18next-icu a été écarté après essai : avec i18next 25, le message ICU ressortait brut, accolades comprises, sans erreur.

Pourquoi un seul catalogue pour l’API, les e-mails et l’interface ?

Parce que trois catalogues séparés divergent. Le libellé d’un même refus finit par différer entre l’erreur renvoyée par l’API et l’écran qui l’a provoquée. Avec un paquet partagé, un message a un identifiant unique (errors.permissionMissing, email.invitation.subject, admin.members.title) lu par les trois surfaces.

Le fichier anglais est la source et porte toutes les clés. Une autre langue ne porte que les clés dont la valeur diffère ; une clé absente est héritée de l’anglais à l’exécution. Un méta-test vérifie en plus qu’à clé égale, chaque langue déclare les mêmes arguments ICU avec le même type : une traduction qui oublierait {email} ou remplacerait un plural par un {count} nu fait échouer le test.

Pourquoi renvoyer un messageKey plutôt qu’une phrase ?

Le code qui lève une erreur est celui qui en sait le moins sur la personne qui la lira. Le kit lève donc ses erreurs avec un identifiant (AppErrors.*), et un filtre d’exception les rend dans la langue de la requête, toujours sous la même forme :

{
  "statusCode": 403,
  "messageKey": "errors.permissionMissing",
  "message": "Il vous manque la permission « Consulter la marque, les langues et les modèles d'e-mail ».",
  "locale": "fr",
  "details": { "permission": "branding:read" }
}

Un client réagit sur messageKey et lit les valeurs brutes dans details, sans analyser une phrase traduite. L’identifiant devient une clé publique : le renommer est une rupture, changer la phrase qu’il produit ne l’est pas. Les valeurs techniques (un état read_only, une permission branding:read) sont rendues par leur propre libellé traduit, pour éviter « Cet espace est read_only » chez un lecteur francophone.

Dans quel ordre résoudre la locale d’une requête ?

La locale se résout une fois par requête, puis voyage dans le contexte d’exécution, comme le tenant. Le premier critère qui répond l’emporte :

RangSourceRemarque
1préférence du compte (user_preferences)suit le compte, pas l’espace
2en-tête Accept-Languagevaleurs de qualité q= respectées, fr-BE ramené à fr
3langue par défaut de l’espace (tenant_settings.default_locale)réglage du tenant
4enrepli final

Une erreur levée avant l’authentification (401, route inconnue) est rendue dans la langue d’Accept-Language, déjà posée à l’entrée de la requête.

Comment saas-core l’intègre

  • packages/i18n/src/index.ts : IntlMessageFormat, SUPPORTED_LOCALES, resolveLocale, localeFromAcceptLanguage, la fonction t avec cache et repli sur en.
  • packages/i18n/src/locales/en.json et fr.json : le catalogue partagé, pluriels compris.
  • packages/i18n/test/i18n.spec.ts : règles de pluriel par langue, pas de clé orpheline, pas de surcharge identique à la source.
  • packages/i18n/test/icu-arguments.spec.ts : mêmes arguments, de même type, dans toutes les langues.
  • apps/api/src/i18n/app.exception.ts et i18n-exception.filter.ts : erreurs par identifiant et enveloppe { statusCode, messageKey, message, locale }.
  • apps/api/src/i18n/locale.service.ts : l’ordre de résolution de la locale.
  • packages/ui/src/i18n.tsx (LocaleProvider, useT) et packages/mail/src/templates.ts : les deux autres lecteurs du même catalogue.

Les traducteurs n’éditent pas ces JSON à la main : ils passent par un outil d’atelier, décrit dans Tolgee auto-hébergé pour traduire un SaaS. La langue par défaut d’un espace vit dans une table protégée par la sécurité au niveau des lignes, présentée dans PostgreSQL RLS pour le multi-tenant.

Le paquet i18n partagé, son filtre d’erreurs et ses méta-tests font partie du kit ; la page tarifs détaille ce qui est inclus.