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 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
}
}
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
}
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
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.
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.