Notes sur les types et la grammaire de JavaScript

Ce billet date de plusieurs années, ses informations peuvent être devenues obsolètes.

Voici quelques unes de mes notes sélectives sur le livre You Don't Know JS: Types & Grammar de Kyle Simpson.

Ça fait un moment qu'elles traînent sur mon ordinateur, et comme j'ai un peu de temps je me suis décidé à les mettre "au propre".

J'ai agrémenté le tout de quelques liens et parfois aussi de mon avis personnel qui, j'en suis certain, vous intéressera au plus haut point si tant est que vous arriviez au bout de ce billet, somme toute, long et chiant… comme JavaScript :D

1) Types

JavaScript définit 7 types primitifs :

  • null
  • undefined
  • boolean
  • number
  • string
  • object
  • symbol (introduit avec ES6)

L'opérateur typeof renvoie une chaîne de caractères indiquant le type de son opérande :

typeof undefined    === "undefined"; // true
typeof true         === "boolean";   // true
typeof 42           === "number";    // true
typeof "42"         === "string";    // true
typeof { life: 42 } === "object";    // true
typeof Symbol()     === "symbol";    // true, added in ES6!

typeof null         === "object"; // true

typeof null retourne object, ce qui laisse penser que null est un objet alors que c'est une valeur primitive ! Voir The history of “typeof null” et harmony:typeof_null.

2) Valeurs

Tableaux

Attention avec l'utilisation de l'opérateur delete sur un tableau car il va bien effacer l'élément ciblé du tableau mais ne met pas à jour la propriété length !

Le mot clé undefined

undefined peut faire référence à 3 concepts différents :

  1. le type Undefined
  2. la valeur undefined
  3. une propriété de l'objet global

Lorsque undefined est une propriété de l'objet global, on peu aussi y accéder par l'intermédiaire d'une variable.

Or undefined n'est pas un mot réservé (tout comme NaN et Infinity) et peut donc être utilisé comme un identifiant (un nom de variable) dans un scope local !

L'opérateur void

L'opérateur void permet d'évaluer une expression et de toujours retourner la valeur primitive undefined :

function doSomething() {
    if (!APP.ready) {
        // Try again later.
        return void setTimeout(doSomething, 100);
    }
    var result;
    // do some other stuff
    return result;
}

// Were we able to do it right away?
if (doSomething()) {
    // Handle next tasks right away.
}

La valeur NaN

NaN est une valeur spéciale : NaN n'est jamais égale à elle-même.

La fonction prédéfinie isNaN() possède un défaut :

var a = 2 / "foo";
var b = "foo";

a; // NaN
b; // "foo"

window.isNaN(a); // true
window.isNaN(b); // true -- ouch!

Si "foo" n'est pas un nombre, ça n'est pas pour autant la valeur NaN ! Ce bug est présent dans JavaScript depuis le début.

À partir d'ES6 :

  • une alternative qui fixe le bug est proposée avec Number.isNaN()
  • la méthode Object.is() est utile avec Nan, -0 et +0.

Stratégie d'évaluation

Dans un passage par valeur, la valeur de l'argument est copiée dans une variable locale, aucune modification ne modifie l'argument passé en paramètre, parce que ces modifications ne s'appliquent qu'à une copie de cette dernière.

Dans un passage par référence, ce n'est pas la valeur de l'argument qui est utilisée dans le corps de la fonction, c'est l'argument lui-même qui est utilisé et modifié : ces modifications resteront donc visibles après le retour de la fonction.

On pourrait dire qu'en JavaScript les primitives immuables (string, number, etc.) sont passées par valeur et que les valeurs mutables (object, etc.) sont passées par référence.

Personnellement je préfère parler d'assignation/passage par partage d'objet :)

3) Objets natifs

Syntaxe littérale et boxing

Les objets natifs peuvent être créés par l'intermédiaire de constructeurs ou par l'intermédiaire d'une syntaxe littérale.

Lorsqu'on utilise la syntaxe littérale pour créer un objet String, Number ou Boolean on obtient une valeur primitive. Il est cependant possible d'utiliser les méthodes de l'objet natif correspondant. JavaScript convertira alors implicitement et automatiquement le littéral en un objet. Ce processus est parfois appelé boxing, auto-boxing ou wrapping.

La syntaxe littérale est toujours conseillée lorsqu'elle est possible.

Unboxing

Lorsqu'un objet enveloppe une valeur primitive on peut utiliser valueOf() pour la récupérer :

var a = new String("abc");
var b = new Number(42);
var c = new Boolean(true);

a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true

Constructeur Array()

Si on ne passe qu'un seul argument de type number au constructeur Array, il ne considère pas la valeur comme un élément de tableau mais à la place la valeur est utilisée pour définir la propriété length du tableau !

Constructeurs Date() et Error()

Ces deux constructeurs n'ont pas d'alternative littérale.

4) Conversion de type

JavaScript est un langage dynamique. Le type des ses objets n'est pas déclaré à l'avance mais il est déterminé au moment de l'exécution.

Quand un opérateur ou une instruction attend un type particulier, JavaScript va convertir automatiquement une donnée vers ce type.

La conversion d'une donnée d'un type vers un autre peut être automatique et implicite (on parle de coercion), ou explicite (on parle de type casting).

Une coercion en JavaScript résulte toujours en une valeur primitive (string, number, boolean), jamais en un objet complexe (object, function).

Règles de conversions automatiques

Les algorithmes de conversions implicites sont décrits dans les spécifications ECMAScript par un jeu d'opérations de conversions abstraites : ToPrimitive, ToBoolean, ToNumber, ToString, ToInt32, ToObject etc.

La conversion en chaîne JSON

Si elle existe, la méthode toJSON() d'un object sera utilisée pour obtenir une valeur lors de la sérialisation JSON via JSON.stringify().

toJSON() doit donc être interprété comme to a JSON-safe value suitable for stringification et non comme to a JSON string.

Valeurs falsy

En JavaScript, toutes les valeurs peuvent être classées en 2 catégories :

  • celles qui deviennent false lors d'une conversion en booléen
  • toutes les autres qui deviennent true

Si une valeur figure sur cette liste, c'est une valeur falsy :

  • false
  • ""
  • 0 (+0, -0)
  • NaN
  • null
  • undefined

…et aussi document.all :)

Valeurs truthy

[], {}, et function(){} ne sont pas dans la liste des valeurs falsy, ce sont des valeurs truthy !

La liste des valeurs truthy est infiniment longue. Il vaut mieux faire la liste des valeurs falsy et la consulter.

Conversion explicite

Conversion explicite : String <=> Number

Pour convertir des chaînes ou des nombres on utilise les fonctions natives String() et Number() mais, et c'est très important, sans utiliser le mot clé new. Ainsi on ne crée pas des objets qui enveloppent une valeur primitive :

var a = 42;
var b = String(a);

var c = "3.14";
var d = Number(c);

b; // "42"
d; // 3.14

Voir les différences entre les chaînes primitives et les objets chaînes.

Il existe d'autres moyens de faire une conversion explicite :

var a = 42;
var b = a.toString();
b;  // "42"

var c = "3.14";
var d = +c;
d;  // 3.14

+c dans ce contexte est l'opérateur unaire plus. Il convertit explicitement son opérande en nombre si possible.

L'opérateur de négation unaire précède son opérande et retourne son opposé :

1 + - + + + - + 1;  // 2, l'opérateur unaire plus a une précédence de droite à gauche
1 + (- + + + - + 1);

Conversion explicite : Date => Number

Un usage courant de l'opérateur unaire plus est de convertir un objet Date en nombre, le résultat est le timestamp unix :

var d = new Date("Mon, 18 Aug 2014 08:53:06 CDT");
+d; // 1408369986000

On trouve parfois ce genre de code :

var timestamp = +new Date();

Il est préférable d'utiliser une syntaxe plus explicite :

var timestamp = new Date().getTime();
// var timestamp = (new Date()).getTime();
// var timestamp = (new Date).getTime();

var timestamp = Date.now();  // ES5

L'étrange histoire de l'opérateur ~ (NON binaire)

Les opérateurs binaires traitent leurs opérandes comme des séquences de 32 bits (des zéros et des uns), plutôt que comme des nombres décimaux, hexadécimaux ou octaux.

En interne cette conversion est contrôlée par l'opération de conversion abstraite ToInt32 (ES5, section 9.5). ToInt32 commence par lancer la conversion ToNumber, ça veut dire que "123" deviendra d'abord 123 avant de passer par ToInt32.

L'opérateur ~ fait d'abord une conversion en séquence de 32 bits, puis effectue l'opération NON (NOT) sur chaque bit.

~x est grosso modo la même chose que -(x + 1). C'est un peu bizarre, mais plus simple à comprendre.

~42;    // -(42+1) ==> -43

Dans l'équation -(x + 1), la seule valeur qui pourrait produire un 0 (-0 techniquement) est -1. Ça veut dire que ~ utilisé avec des nombres produira une valeur falsy si on lui passe -1 :

var a = "Hello World";

~a.indexOf("lo");  // -4   <-- truthy!
~a.indexOf("ol");  // 0    <-- falsy!
!~a.indexOf("ol");  // true

~ prend la valeur de retour de indexOf() et la transforme : pour le retour -1 on obtient 0, sinon une valeur truthy.

Tronquer les bits

Certains développeurs utilisent le double tilde ~~ pour tronquer la partie décimale d'un nombre, c'est à dire pour le convertir en entier.

Le fonctionnement de ~~ est le suivant : le premier ~ applique la conversion ToInt32 et l'inversion des bits, puis le second ~ applique une autre inversion des bits, ce qui remet les bits dans leur état d'origine. Le résultat est donc la seule conversion de ToInt32.

Parser les chiffres dans une chaîne

À partir d'ES5, parseInt() supprime l'interprétation octale. Toutefois de nombreuses implémentations n'ont pas adopté ce comportement, il faut faire attention.

Conversion explicite : * => Boolean

Les bonnes options pour une conversion explicite en booléen sont Boolean(a) et !!a.

L'opérateur NON logique ! renvoie false si son opérande unique peut être converti en true, sinon il renvoie true. Le problème est qu'il convertit une valeur truthy vers falsy et vice-versa. Du coup pour convertir en booléen on utilise le double !!, car le second ! va rétablir la situation :

var a = "0";
var b = [];
var c = {};

!!a;    // true
!!b;    // true
!!c;    // true

var d = "";
var e = 0;
var f = null;
var g;

!!d;    // false
!!e;    // false
!!f;    // false
!!g;    // false

Conversion implicite

Voici, à mon sens, un des points les plus épineux en JavaScript : la coercion ou conversion implicite.

Si elle existe aussi en Python, elle est beaucoup moins présente qu'en JavaScript : je pense à la conversion string/unicode (qui n'existe plus en Python 3), à la conversion int/float dans une division ou à l'utilisation d'une string avec l'opérateur *.

Certains considèrent que plus un langage offre de conversions implicites, plus il est faiblement typé. C'est le débat typage fort ou faible dans lequel je ne souhaite pas entrer :)

La position de l'auteur est de dire qu'une fois les règles de conversions implicites bien assimilées, il est du devoir du développeur de les utiliser ou de les éviter sciemment.

Une étude tend à démontrer que la conversion implicite est largement utilisée en JavaScript et qu'elle ne serait pas si catastrophique que ça : The Good, the Bad, and the Ugly: An Empirical Study of Implicit Type Conversions in JavaScript.

À mon sens : This Is Madness! Je ferais davantage confiance à quelque chose comme restrict mode :)

Et je citerai Brendan Eich pour terminer cette introduction :

Implicit conversions are my biggest regret in JS's rushed design, bar none. Even including 'with'!

Conversion implicite : String <=> Number

L'opérateur + sert à la fois pour l'addition de nombres et pour la concaténation de chaînes.

Si un des opérandes de l'opérateur + est une chaîne alors l'opération sera une concaténation, sinon une addition.

Quand une des opérandes de + est un object (array inclus), il appelle d'abord l'opération abstraite ToPrimitive (section 9.1) sur la valeur, ce qui appelle l'algorithme [[DefaultValue]] (section 8.12.8) avec un hint de type number :

var a = [1,2];
var b = [3,4];

a + b; // "1,23,4"

L'opération [[DefaultValue]] va commencer par lancer valueOf() sur les tableaux et va échouer à produire des primitives, puis continuer avec toString(). Les deux tableaux deviennent donc "1,2" et "3,4". Maintenant + va concaténer les deux chaînes : "1,23,4".

Autre bizarrerie à connaître qui découle du fonctionnement de l'opération abstraite ToPrimitive :

  • a + "" invoque valueOf() sur la valeur a, dont la valeur de retour est finalement convertie en string via l'opération abstraite ToString
  • mais String(a) invoque toString() directement

C'est la porte ouverte à toutes les fenêtres :

var a = {
    valueOf: function() { return 42; },
    toString: function() { return 4; }
};
a + "";     // "42"
String(a);  // "4"

L'opérateur - sert uniquement pour la soustraction. a - 0 convertit implicitement la valeur de a en number.

Même histoire que + quand une des opérandes de - est un object :

var a = [3];
var b = [1];

a - b; // 2

Les deux tableaux doivent devenir des nombres, mais ils finissent en étant d'abord convertis implicitement en chaînes avec toString(), puis en nombres pour que la soustraction puisse fonctionner.

Conversion implicite : * => Boolean

Types d'opérations qui forcent (implicitement) une conversion booléenne :

  1. l'expression de test dans une instruction if ()
  2. l'expression de test (la deuxième clause) dans une instruction for (..;..;..)
  3. l'expression de test dans les boucles while () et do..while()
  4. l'expression de test (première clause) avec l'opérateur ternaire conditionnel ? :
  5. les opérandes des opérateurs || ("OU logique") et && ("ET logique")

Toute valeur utilisée dans un de ces contextes qui n'est pas déjà une valeur booléenne sera implicitement convertie en boolean suivant les règles de l'opération abstraite ToBoolean.

Opérateurs || et &&

Ces opérateurs ne retournent pas de booléen en JavaScript (ni en Python d'ailleurs). Le résultat est un de leurs opérandes. Il faut y penser comme des opérateurs de sélection d'opérande plutôt que comme des opérateurs logiques.

Si leurs opérandes ne sont pas des booléens, une conversion implicite ToBoolean se produit.

Pour l'opérateur ||, si le test est true, le résultat de l'expression || est la valeur de son premier opérande. Sinon la valeur de son second opérande.

Pour l'opérateur &&, si le test est true, le résultat de l'expression && est la valeur de son second opérande. Sinon la valeur de son premier opérande.

Égalité faible et égalité stricte

== autorise la conversion implicite et === l'interdit.

Algorithme de comparaison abstraite

La première clause de l'algorithme indique que si deux valeurs sont du même type elles sont comparées par identité. Par exemple 42 est seulement égal à 42, et "abc" est seulement égal à "abc".

Exceptions aux attentes :

  • NaN n'est jamais égal à lui même
  • +0 et -0 sont égaux

La dernière disposition de la clause 11.9.3.1 concerne l'égalité faible et la comparaison avec des objects (functions et arrays inclus). Deux valeurs de ce type sont égales seulement si elles référencent la même valeur exacte. Pas de conversion implicite ici.

Le reste de l'algorithme spécifie les règles de conversion implicite si les opérandes sont de types différents. La conversion se termine quand les deux valeurs deviennent du même type afin d'être comparées par identité.

Égalité faible et boolean

var a = "42";  // truthy
var b = true;

a == b;  // false

Si un des opérandes de == est un booléen, il est convertit en nombre via ToNumber() (section 11.9.3.6-7) : true devient 1 et false devient 0.

Il est conseillé de ne jamais utiliser == true ou == false.

Égalité faible : comparer null à undefined

Quand null et undefined sont comparés avec ==, ils sont indissociables (section 11.9.3.2-3).

Constructeur Number et valueOf()

new Number(2) passe par la conversion ToPrimitive, et invoque par conséquent valueOf() :

Number.prototype.valueOf = function() {
    return 3;
};

new Number(2) == 3;  // true

Les enfants, ne faîtes pas ça chez vous !

Conversions problématiques avec ==

7 conversions peuvent être problématiques avec == (17 autres au moins sont parfaitement explicables) :

"0" == false;  // true -- UH OH!
false == 0;    // true -- UH OH!
false == "";   // true -- UH OH!
false == [];   // true -- UH OH!
"" == 0;       // true -- UH OH!
"" == [];      // true -- UH OH!
0 == [];       // true -- UH OH!

Utiliser l'égalité faible sans danger

Pour utiliser l'égalité faible (la conversion implicite donc) sans danger, voici les règles à suivre :

  1. Si un des opérandes peut avoir la valeur true ou false, ne jamais utiliser ==
  2. Si un des opérandes peut avoir la valeur [], "", ou 0, considérer sérieusement d'éviter ==

Voir Equality in JavaScript.

5) Grammaire

Expressions et instructions

Une expression produit une valeur unique et peut être utilisée partout où une valeur est attendue (par exemple un argument dans un appel de fonction).

Grossièrement, une instruction effectue une action (par exemple une boucle ou une instruction if).

Partout où JavaScript attend une instruction, on peut aussi écrire une expression, on parle d'instruction d'expression. L'inverse n'est pas vrai : on ne peut pas écrire une instruction là où JavaScript attend une expression (par exemple une instruction if ne peut pas devenir l'argument d'une fonction).

Valeurs de fin des instructions

En JavaScript toutes les instructions ont une valeur de fin (même si cette valeur est undefined).

Cette valeur de retour est visible dans les environnements REPL où l'on peut voir undefined imprimé après certaines instructions.

Elle peut être capturée avec eval() (fortement déconseillé), ou un jour peut-être avec l'expression do en ES7.

Effets de bord des expressions

var a = 42;
var b = a++;

L'expression a++ a deux comportements distincts :

  • d'abord elle retourne la valeur courante de a, soit 42, qui est assignée à b
  • ensuite elle change la valeur de la variable a elle-même en l'incrémentant de un

Lorsque ++ est utilisé en position de préfixe, l'incrémentation de a a lieu avant que la valeur soit assignée.

Labels

{
    foo: bar()
}

Le code ci-dessus est souvent assimilé à un littéral d'object. La syntaxe est légale, même si l'expression n'est pas stockée dans une variable, à cause des instructions étiquetées.

Une instruction étiquetée peut être utilisée avec les instructions break ou continue. Un label permet d'identifier une instruction avec un identifiant pour y faire référence plus tard. C'est une forme limitée de goto.

else if et les blocs optionnels

JavaScript ne dispose pas de clause else if. Tout comme en C, la grammaire de JavaScript rend les accolades {} optionnelles entre else et if.

Priorité des opérateurs

Voir Operator Precedence.

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;
var d = ((a && b) || c) ? ((c || b) ? a : (c && b)) : a;

Points-virgules automatiques

L'insertion automatique de points-virgules (ASI) permet à JavaScript d'assumer l'existence de ; à certains endroits même s'ils n'existent pas.

Les blocs d'instructions ne nécessitent pas d'être terminés par un ;.

La bonne pratique consiste à toujours les ajouter explicitement quand on sait qu'ils sont nécessaires.

Et si c'était à refaire :

I wish I had made newlines more significant in JS back in those ten days in May, 1995. Then instead of ASI, we would be cursing the need to use infix operators at the ends of continued lines, or perhaps or brute-force parentheses, to force continuation onto a successive line. But that ship sailed almost 17 years ago.

6) Appendice

DOM et variables globales

La création d'éléments DOM avec des attributs id entraîne la création de variables globales du même nom :

<div id="foo"></div>
if (typeof foo == "undefined") {
    foo = 42;  // will never run
}
console.log(foo);  // HTML element

<div id="main-foo"></div>
console.log(window["main-foo"]);

Prototypes natifs

Une bonne pratique largement admise est de ne jamais étendre les prototypes natifs.

Peu importe le nom d'une méthode ou d'une propriété qui n'existe pas encore, la possibilité existe qu'elle finisse par être ajoutée dans les spécifications, auquel cas l'extension entrera en conflit.

Avant Coroutines avec asyncio en Python Après Notes sur l'écosystème front-end avec Webpack en 2016

Tag Kemar Joint