Aller au contenu principal

0.1 + 0.2, typeof null, [] + {} : les bizarreries de JavaScript ont une explication

Un total de panier qui affiche 0.30000000000000004, un tri qui place 10 avant 2, une fonction qui perd son this : JavaScript a la réputation d'un langage incohérent. Cet article reprend ses comportements les plus déroutants, montre la règle qui produit chacun d'eux, et donne les réflexes qui évitent les bugs qu'ils provoquent dans un vrai projet.

Guides & tutoriels ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

JavaScript tourne dans tous les navigateurs du monde, et c'est souvent le premier vrai langage de programmation qu'on apprend en se formant au développement web. C'est aussi celui qui alimente le plus de moqueries : des listes entières de résultats absurdes circulent, du genre [] + {} qui donne une chaîne de caractères, ou 3 > 2 > 1 qui renvoie false.

Ces listes amusent, et elles découragent aussi. Pour quelqu'un qui débute, voir un langage contredire l'arithmétique installe une idée tenace : JavaScript serait imprévisible, et il faudrait apprendre ses pièges par cœur sans les comprendre. Beaucoup de personnes en reconversion abandonnent avec cette impression.

Chacune de ces bizarreries obéit pourtant à une règle précise, écrite dans la spécification du langage. Une poignée de règles en expliquent la quasi-totalité, et la plupart des bugs réels qu'elles causent s'évitent avec des réflexes simples. Les exemples qui suivent ont tous été exécutés avec Node.js 22, et donnent le même résultat dans un navigateur récent.

Le problème : des résultats qui semblent absurdes

Un panier d'achat additionne deux articles à 0,10 € et 0,20 €. La console affiche :

0.1 + 0.2          // 0.30000000000000004
0.1 + 0.2 === 0.3  // false
19.99 * 100        // 1998.9999999999998

Plus loin, une liste de prix est triée, et le résultat n'a aucun sens :

[1, 10, 2].sort()  // [1, 10, 2]

Dans un formulaire, une quantité saisie par l'utilisateur produit deux résultats opposés selon l'opération :

"5" + 3   // "53"
"5" - 3   // 2

Aucun de ces résultats n'est un bug du langage. Chacun apparaît dans de vrais projets, sous forme de total faux sur une facture, de tableau mal classé ou de quantité multipliée par dix. Comprendre d'où ils viennent permet de les repérer au premier coup d'œil, et surtout de ne plus écrire le code qui les déclenche.

Le principe : quatre règles expliquent presque tout

Nombres en virgule flottante 0.1 + 0.2, 19.99 * 100, entiers au-delà de 2^53 Conversion automatique de types "5" + 3, [] + {}, 0 == "", 3 > 2 > 1 Tri par défaut en ordre de texte [1, 10, 2].sort() this fixé au moment de l'appel méthode détachée de son objet, callback qui perd son contexte
Les quatre règles qui produisent la plupart des comportements surprenants du langage.

La première règle tient à la façon dont la machine stocke les nombres. JavaScript n'a qu'un seul type numérique, number, codé selon la norme IEEE 754 en double précision, la même que celle utilisée par Python pour ses float ou par Java pour ses double. Ce format écrit les nombres en binaire, et certaines fractions simples en décimal, comme 0,1, n'ont pas d'écriture finie en binaire, exactement comme 1/3 n'en a pas en décimal. La valeur stockée est une approximation très proche, et les petites erreurs s'accumulent lors des calculs.

La deuxième règle est la conversion automatique de types, appelée coercition. Quand un opérateur reçoit des valeurs de types différents, JavaScript convertit l'une ou l'autre selon des règles fixes plutôt que de lever une erreur. L'opérateur + sert à la fois à additionner et à concaténer du texte : s'il voit une chaîne, il concatène. L'opérateur - ne sert qu'à soustraire, donc il convertit tout en nombre.

Les deux dernières règles concernent des fonctions précises : la méthode sort, qui compare par défaut les éléments comme du texte, et le mot-clé this, dont la valeur dépend de la manière dont une fonction est appelée et non de l'endroit où elle est écrite. Le reste découle de ces quatre mécanismes, avec quelques choix historiques du langage.

Les bizarreries une à une, et la bonne pratique

Les calculs d'argent

Le résultat 0.30000000000000004 se retrouve dans n'importe quel langage qui utilise le même format, Python compris. Ce n'est donc pas une particularité de JavaScript, c'est une propriété de l'arithmétique des ordinateurs. La correction ne consiste pas à arrondir au hasard, mais à ne jamais manipuler de l'argent en euros à virgule : on compte en centimes, avec des entiers.

const panier = [1999, 1050, 499];           // prix en centimes
const total = panier.reduce((somme, prix) => somme + prix, 0);
console.log(total);                          // 3548
 
const affichage = new Intl.NumberFormat("fr-FR", {
  style: "currency",
  currency: "EUR",
}).format(total / 100);
console.log(affichage);                      // "35,48 €"

La division par 100 n'intervient qu'au moment d'afficher, et Intl.NumberFormat produit le format français avec la virgule et le symbole euro. Méfiance aussi envers toFixed pour arrondir : (1.005).toFixed(2) renvoie "1.00", parce que la valeur réellement stockée est légèrement inférieure à 1,005. Un dernier point lié au format : au-delà de Number.MAX_SAFE_INTEGER, soit 9 007 199 254 740 991, les entiers ne sont plus représentés exactement. Les très grands identifiants renvoyés par certaines API doivent donc être traités comme du texte, ou avec le type BigInt.

Les additions qui concatènent

Tout ce qui vient d'un champ de formulaire, d'une URL ou du stockage du navigateur arrive sous forme de chaîne de caractères. D'où le classique "5" + 3 qui donne "53". La bonne pratique : convertir explicitement dès la lecture, et vérifier le résultat.

const quantite = Number(champQuantite.value);
 
if (!Number.isInteger(quantite) || quantite <= 0) {
  afficherErreur("Quantité invalide");
}

La conversion avec Number renvoie NaN, pour « not a number », si la saisie n'est pas un nombre. Cette valeur a ses propres surprises : typeof NaN vaut "number", et NaN === NaN vaut false, parce que la norme IEEE 754 impose qu'elle ne soit égale à rien, pas même à elle-même. Pour la détecter, Number.isNaN fait le travail.

Les cas les plus célèbres suivent la même logique. [] + {} convertit les deux opérandes en texte : un tableau vide devient une chaîne vide, un objet devient "[object Object]", et le résultat est leur concaténation. 3 > 2 > 1 s'évalue de gauche à droite : 3 > 2 donne true, puis true > 1 convertit true en 1, et 1 n'est pas supérieur à 1.

Double ou triple égal

L'opérateur == applique la coercition avant de comparer, === compare sans rien convertir. La version double produit des résultats que personne ne peut retenir :

0 == ""            // true
0 == "0"           // true
"" == "0"          // false
null == undefined  // true
null === undefined // false

Les deux premières lignes sont vraies et la troisième fausse : l'égalité double n'est même pas transitive. La règle à appliquer est simple, et c'est mon opinion la plus tranchée sur le langage : toujours ===, partout, sans exception à mémoriser. La seule tolérance que certaines équipes s'accordent est valeur == null, qui teste en une fois null et undefined, et encore, l'opérateur ?? couvre aujourd'hui la plupart de ces cas de façon plus lisible.

typeof null

typeof null renvoie "object". Ici, aucune règle élégante : c'est une erreur de la toute première version du langage, en 1995, conservée depuis parce que la corriger casserait des millions de pages existantes. Une proposition de correction a été écartée par le comité de normalisation pour cette raison. La conséquence pratique : tester qu'une valeur est un objet utilisable demande les deux conditions.

if (valeur !== null && typeof valeur === "object") {
  // valeur est un objet ou un tableau
}

Le tri qui range 10 avant 2

Sans argument, sort convertit chaque élément en texte et les compare dans l'ordre des caractères, comme un dictionnaire : "10" commence par "1", donc il passe avant "2". Pour trier des nombres, il faut fournir une fonction de comparaison.

[1, 10, 2].sort((a, b) => a - b);   // [1, 2, 10]
 
const noms = ["Émile", "zoé", "Adam"];
noms.sort((a, b) => a.localeCompare(b, "fr"));
// ["Adam", "Émile", "zoé"]

La version avec localeCompare règle aussi un problème plus discret : le tri par défaut place les lettres accentuées et les majuscules selon leur code informatique, pas selon l'ordre alphabétique français. Autre détail qui surprend : sort modifie le tableau d'origine. Quand il doit rester intact, toSorted renvoie une copie triée.

map(parseInt)

Un cas qui piège même des développeurs expérimentés :

["1", "2", "3"].map(parseInt)   // [1, NaN, NaN]

La méthode map appelle la fonction avec trois arguments : l'élément, son index et le tableau. Or parseInt accepte un deuxième argument, la base de numération. Le deuxième appel devient parseInt("2", 1), base invalide, et le troisième parseInt("3", 2), où 3 n'est pas un chiffre binaire. La correction : .map(Number), ou une fonction fléchée qui ne transmet que l'élément.

Le this qui disparaît

const utilisateur = {
  nom: "Lina",
  saluer() {
    return `Bonjour ${this.nom}`;
  },
};
 
utilisateur.saluer();             // "Bonjour Lina"
 
const saluer = utilisateur.saluer;
saluer();                         // TypeError en mode strict

La valeur de this est fixée au moment de l'appel : avec utilisateur.saluer(), c'est l'objet placé avant le point. Une fois la méthode copiée dans une variable, puis appelée seule, il n'y a plus d'objet devant, et this vaut undefined en mode strict, le mode actif par défaut dans les modules et les classes. Le même phénomène frappe une méthode passée en callback, par exemple à addEventListener ou setTimeout. Les fonctions fléchées, qui reprennent le this de l'endroit où elles sont écrites, ou la méthode bind, règlent le problème.

Les pièges dans un vrai projet

Apprendre les bizarreries par cœur au lieu des règles

Les listes de résultats absurdes circulent sous forme de quiz, et certains entretiens en reprennent. Mémoriser que [] + {} donne "[object Object]" n'apporte rien en soi. Savoir expliquer qu'il s'agit d'une conversion en texte suivie d'une concaténation, si : c'est la même règle qui produit le bug du formulaire. Les questions qui tombent réellement en entretien sont décrites dans l'article sur l'entretien technique junior.

Croire que const rend une valeur immuable

const empêche de réaffecter la variable, pas de modifier l'objet qu'elle contient. Un const panier = [] accepte très bien un panier.push(article). Et copier un objet avec const copie = panier ne crée pas de copie : les deux noms désignent le même objet, et toute modification de l'un se voit dans l'autre. Pour une copie indépendante, structuredClone existe dans les navigateurs récents et dans Node.js.

Faire taire les symptômes

Un total de 1,10 € plus 2,20 € affiché 3.3000000000000003 se « corrige » vite avec un toFixed(2) à l'affichage. Le bug reste dans les calculs et réapparaît dans une facture ou une comparaison. Remonter à la cause, ici les euros à virgule, demande une méthode, celle décrite dans l'article apprendre à débugger.

Se passer d'outils qui voient ces erreurs

Un linter comme ESLint signale l'usage de == et bien d'autres pièges avant même l'exécution. TypeScript va plus loin et refuse d'additionner une chaîne et un nombre sans conversion explicite. Ces outils ne remplacent pas la compréhension des règles, ils empêchent simplement de les oublier un vendredi soir. L'article sur les erreurs de typage TypeScript qui révèlent un mauvais design montre ce que le typage apporte au-delà de ces cas.

Apprendre le langage par ses règles

Ces bizarreries font une excellente porte d'entrée, à condition de les prendre comme des exercices : pour chaque résultat surprenant, chercher la règle avant de lire la réponse. Les mêmes règles reviennent ensuite partout, dans la manipulation du DOM, les formulaires et les données reçues d'un serveur. L'étape suivante du langage, la gestion de l'asynchrone, a ses propres surprises, détaillées dans l'article sur les callbacks, les Promises et async/await.

Pour les personnes en reconversion, les développeurs juniors et les curieux qui veulent apprendre JavaScript de façon structurée, la formation JavaScript fondamentaux : le langage du web pose ces bases. La suite logique est JavaScript avancé : async, POO et patterns modernes, puis TypeScript pour les développeurs JavaScript. Le parcours complet est décrit dans devenir développeur web junior en 6 mois.

Un test rapide pour vérifier ce qui a été retenu : ouvrir la console du navigateur, taper Math.max(), et essayer d'expliquer le résultat avant de chercher.

Questions fréquentes

Pourquoi 0.1 + 0.2 ne fait pas 0.3 en JavaScript ?

Les nombres sont stockés en binaire selon la norme IEEE 754, et 0,1 comme 0,2 n'ont pas d'écriture binaire exacte. Les valeurs stockées sont des approximations, et leur somme diffère très légèrement de 0,3. Le même résultat apparaît en Python, en Java ou en C avec des nombres à virgule flottante.

Comment calculer des prix sans erreur d'arrondi ?

En travaillant en centimes avec des nombres entiers, et en ne divisant par 100 qu'au moment de l'affichage, par exemple avec Intl.NumberFormat. Pour des calculs financiers plus complexes, avec des taux et des arrondis réglementaires, une bibliothèque de nombres décimaux reste la solution la plus sûre.

Faut-il toujours utiliser === plutôt que == ?

Oui, c'est la règle la plus simple et la plus sûre. L'égalité triple compare sans convertir les types, alors que l'égalité double applique des conversions dont les résultats sont difficiles à prévoir. La plupart des configurations de linter signalent d'ailleurs l'usage de l'égalité double.

Pourquoi typeof null renvoie-t-il "object" ?

C'est une erreur de la première implémentation du langage, conservée pour ne pas casser les sites existants. Pour tester qu'une valeur est un objet, il faut vérifier à la fois qu'elle est différente de null et que son typeof vaut "object".

JavaScript est-il un bon premier langage malgré ces bizarreries ?

Pour le développement web, oui : il tourne dans tout navigateur sans installation, et chaque ligne écrite produit un effet visible sur une page. Ses bizarreries reposent sur un petit nombre de règles qui s'apprennent. L'essentiel est de les comprendre plutôt que de les contourner au cas par cas.

TypeScript supprime-t-il ces problèmes ?

En partie. TypeScript bloque les mélanges de types à la compilation, comme additionner une chaîne et un nombre sans conversion. Il ne change rien au fonctionnement des nombres à virgule, au tri par défaut ou à la valeur de this, puisque le code produit reste du JavaScript.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous