Sélectionnez votre langue

Les décimaux sont des chaînes

Écrire et valider

Les décimaux sont des chaînes — et voici pourquoi

Dans un message Telebib2, un montant s'écrit "1234.56", guillemets compris. Un taux aussi. Une surface aussi. C'est la première chose qui surprend en ouvrant un payload et la question vient tout de suite : pourquoi ne pas utiliser les nombres de JSON, qui existent précisément pour ça ? La réponse tient en une phrase : parce qu'ils ne fonctionnent pas !
Mais elle mérite d'être démontrée, parce que la démonstration est contre-intuitive.

Le point de départ : une contrainte qui semblait évidente

EDIFACT sait combien de décimales porte une valeur : un qualifiant l'accompagne. En JSON Schema, la façon naturelle de traduire « au plus 9 décimales » est le mot-clé multipleOf, avec la valeur 1e-9. C'est ce qui avait été écrit.

Puis un taux d'intérêt de 3,5 % a été rejeté par le validateur, sur un schéma de prêt. La valeur 0.035 est aussi banale qu'on puisse l'imaginer et le schéma la refusait.

La cause n'est pas un bug du validateur. 0.035 n'a pas de représentation exacte en binaire : la machine stocke 0.034999999999999998…. Le reste de la division n'est pas nul et le validateur conclut, en toute rigueur, que la valeur n'est pas un multiple de 1e-9.

La contrainte telle qu'elle était écrite

{

  "percentage": {

    "type": "number",

    "minimum": -1,

    "maximum": 1,

    "multipleOf": 1e-8

  }

}

Ce que la machine calcule réellement

>>> 0.035 % 1e-8

9.999999999999444e-09      # attendu : 0.0
La contrainte telle qu'elle avait été écrite : traduire « au plus 9 décimales » par multipleOf 1e-8 semblait naturel. Mais 0.035 n'a pas de représentation exacte en binaire : la machine stocke 0.034999999999999998, le reste de la division par 1e-8 n'est pas nul, et le validateur conclut en toute rigueur que la valeur n'est pas un multiple. Ce n'est pas un bug du validateur.
Fig. 01 — La contrainte qui ne tient pas

La correction qui n'en était pas une

Premier réflexe : retirer multipleOf des taux et des décimaux génériques, où la précision fine relève de toute façon de la couche métier. Mais le conserver sur les montants, avec multipleOf: 0.01 : deux décimales, une valeur ronde, cela paraissait sûr.

Cela ne l'était pas. Mis à l'épreuve sur de vraies valeurs, multipleOf: 0.01 rejette la majorité des montants légitimes. Et les rares qui passent, passent par chance : leur représentation binaire tombe juste.

C'est le pire des cas de figure. Une contrainte qui rejette tout serait détectée en cinq minutes ; une contrainte qui accepte 1452.50 et refuse 1234.56 ressemble à un problème de données et on cherche du mauvais côté pendant des heures.

La correction « prudente »

{

  "amount": {

    "type": "number",

    "multipleOf": 0.01

  }

}
Le verdict est erratique, pas restrictif. Avec multipleOf 0.01 sur les montants, 1234.56, 145.23, 0.07 et -1234.56 sont rejetés parce que leur division donne un reste, tandis que -50.00 et 1452.50 sont acceptés parce que leur représentation binaire tombe juste. Une contrainte qui rejette tout serait détectée en cinq minutes ; une contrainte erratique ressemble à un problème de données, et on cherche du mauvais côté pendant des heures.
Fig. 02 — Le verdict est erratique, pas restrictif

La décision : une chaîne décimale

Le choix réel n'était donc pas entre « des nombres avec une contrainte fiable » et « des chaînes ». Il était entre des chaînes qui garantissent le nombre de décimales et des nombres qui ne garantissent rien. Formulé ainsi, il se tranche seul.

Les montants et les mesures sont passés en chaînes les premiers. Les taux et décimaux génériques ont suivi quelques semaines plus tard, pour une raison de cohérence : laisser un taux s'écrire 0.035 à côté d'un montant écrit "1234.56", c'était conserver deux représentations pour la même nature de donnée. Exactement le genre de disparité que la norme existe pour supprimer.

Le résultat est net : le registre ne contient plus un seul "type": "number". Toute valeur à virgule est une chaîne. Seuls restent numériques les entiers : comptages de personnes, de véhicules, numéros de version.

Les primitifs tels qu'ils sont publiés

"amount": {

  "type": "string",

  "pattern": "^-?(0|[1-9]\\d{0,15})(\\.\\d{1,2})?$"

},

"percentage": {

  "type": "string",

  "pattern": "^-?(0|[1-9]\\d{0,15})(\\.\\d{1,9})?$"

},

"measure": {

  "type": "string",

  "pattern": "^(0|[1-9]\\d{0,15})(\\.\\d{1,2})?$"

},

"count": {

  "type": "integer",

  "minimum": 0

}

Dans un message

{

  "premiumAmount": "1234.56",

  "commissionPercentage": "0.125",

  "livingAreaMeasure": "142.50",

  "numberOfInsuredPersons": 3

}
Le choix tel qu'il se posait vraiment. Il n'était pas entre des nombres avec une contrainte fiable et des chaînes : la première option n'existait pas. Il était entre des chaînes qui garantissent le nombre de décimales et des nombres qui ne garantissent rien. Le registre ne contient plus un seul type number ; seuls les entiers, comptages et numéros de version, restent numériques. C'est aussi plus fidèle à EDIFACT, qui transporte déjà ces valeurs comme du texte.
Fig. 03 — Le choix tel qu'il se posait vraiment

Ce que cela change dans votre code

Une seule règle à retenir : ne faites jamais transiter ces valeurs par un flottant. Chaque langage a un type décimal exact : decimal en C#, BigDecimal en Java, Decimal en Python. Passer par un double ou un float, même une seule fois, réintroduit précisément l'imprécision que la chaîne était là pour éviter.

JavaScript demande une attention particulière : il n'a pas de type décimal natif et JSON.parse ne vous protège pas puisque la valeur arrive déjà en chaîne. Si vous calculez sur ces valeurs, prenez une bibliothèque décimale ; si vous ne faites que les transporter, laissez-les en chaîne d'un bout à l'autre.

Comparez les valeurs, pas les chaînes. Le motif accepte "51", "51.3" et "51.30" : les zéros de fin ne sont pas requis. Ces trois écritures sont valides et représentent des montants différents ou égaux selon les cas mais attention, une égalité de chaînes vous donnera la mauvaise réponse. En revanche "051" est refusé : pas de zéro initial.

À l'émission, respectez le nombre de décimales du primitif : deux pour un montant ou une mesure, neuf pour un taux ou un décimal générique. Au-delà, le schéma rejette.

Python

from decimal import Decimal



prime = Decimal(msg["premiumAmount"])   # exact

taux  = Decimal(msg["commissionPercentage"])

commission = prime * taux               # 154.32



# À ne jamais faire :

prime = float(msg["premiumAmount"])     # précision perdue

C#

decimal prime = decimal.Parse(

    msg.PremiumAmount, CultureInfo.InvariantCulture);

Égalité : par la valeur, jamais par la chaîne

"51.30" == "51.3"                     # False  — piège

Decimal("51.30") == Decimal("51.3")   # True   — correct
Ne faites jamais transiter ces valeurs par un flottant : chaque langage a un type décimal exact, decimal en C#, BigDecimal en Java, Decimal en Python ; JavaScript n'a pas de type décimal natif, prenez une bibliothèque décimale ou laissez les valeurs en chaîne. Comparez les valeurs et non les chaînes : la chaîne 51.30 n'est pas égale à la chaîne 51.3, alors que les décimaux correspondants le sont. À l'émission, respectez le nombre de décimales du primitif : deux pour un montant ou une mesure, neuf pour un taux.
Fig. 04 — Ce que cela change dans votre code

Ce que vous perdez et ce qui vous revient

Le choix a un prix et autant l'annoncer clairement. Une chaîne ne se compare pas numériquement : les mots-clés minimum et maximum ne s'appliquent plus. Le schéma sait qu'un montant a la bonne forme ; il ne sait plus dire qu'il est trop grand.

Concrètement, l'ancienne borne des taux (qui les cantonnait à une plage raisonnable) a disparu du schéma. Un taux de 500 % passe désormais la validation sans broncher : il s'écrit "5.0" et le motif est respecté.

Ce contrôle-là vous revient. Ce n'est pas une régression déguisée : la borne précédente reposait sur une contrainte dont on vient de voir qu'elle ne tenait pas ses promesses. Mieux vaut une garantie limitée mais exacte et une règle métier explicite dans votre code, qu'une garantie affichée sur laquelle personne ne peut compter.

Ce que le schéma garantit encore : il accepte 1234.56, 51, 51.30 et -1234.56, et rejette 051 pour son zéro initial, 1234.567 pour ses trois décimales, 1 234,56 pour l'espace et la virgule, et le nombre 1234.56 parce que ce n'est pas une chaîne. Ce qu'il ne garantit plus : minimum et maximum ne s'appliquent pas aux chaînes, un taux de 500 % s'écrit 5.0 et passe la validation. Un pourcentage est une fraction décimale : 1.0 vaut 100 %. Mieux vaut une garantie limitée mais exacte qu'une garantie affichée sur laquelle personne ne peut compter.
Fig. 05 — Ce que le schéma garantit encore, et ce qu'il ne garantit plus

Cette décision a été révisée deux fois : une première correction partielle, puis une seconde qui a montré que la première reposait sur une hypothèse fausse. Les deux révisions sont consignées avec les mesures qui les ont motivées, l'hypothèse initiale comprise.

C'est la raison d'être des notes de décision de la norme : quand un choix surprend, et celui-ci surprend, il doit être possible de remonter à ce qui l'a produit sans avoir à faire confiance sur parole.

Contact

À propos de Telebib2