Accueil / Articles

Ce que le compilateur de TypeScript détecte et que JavaScript laisse passer

Comparez JavaScript et TypeScript côte à côte : inférence de type, annotations, valeurs primitives, any, unions et fonctions typées, ainsi que ce que tsc signale réellement et génère.

Éditeur : REACTAPP.TOP14 min de lecture3122 mots

Sources: JavaScript vs TypeScript: Understanding Type Safety

JavaScript peut exécuter presque n’importe quelle instruction que vous lui donnez, et c’est précisément pour cette raison que certains bugs ne se manifestent qu’en environnement de production. TypeScript ajoute une étape de vérification avant l’exécution du code, mais il est facile de mal comprendre ce que cette étape fait et de quoi elle ne vous protège pas. Ce guide utilise des exemples simples pouvant être typés, allant de la réaffectation de variables à des fonctions typées, afin que vous puissiez voir exactement où JavaScript reste silencieux, où le compilateur TypeScript intervient, et comment le configurer pour que les erreurs empêchent réellement la compilation.

Typage dynamique versus typage statique

JavaScript est de type dynamique. Une variable n’a pas de type fixe ; son type dépend de la valeur qu’elle contient à un moment donné, est déterminé pendant l’exécution du programme et peut changer à tout instant.

TypeScript est un superensemble statiquement typé de JavaScript. Chaque programme JavaScript valide est également un TypeScript syntaxiquement valide, mais TypeScript ajoute un vérificateur de types qui analyse votre code au moment de la compilation, avant que quoi que ce soit ne s’exécute. Le vérificateur supprime ensuite les informations de type et génère du JavaScript pur.

Tout le reste dans ce guide découle de cette seule différence. Déterminer les types à l’exécution rend JavaScript flexible, mais permet que des erreurs passent inaperçues. Définir les types à l’avance rend TypeScript plus strict, mais met en évidence de nombreuses erreurs tôt dans le processus, c’est ce que les gens entendent par « sécurité des types » chez TypeScript. Stricto sensu, cette sécurité est une garantie au moment de la compilation : elle ne vaut que dans la mesure où le vérificateur peut la détecter.

Lorsqu’une variable change de type

Dans du JavaScript pur, commencez avec une variable numérique et vérifiez son type à l’aide de typeof:

let age = 30;
console.log(typeof(age));  // output is number

Quelques lignes plus tard, rien ne vous empêche de stocker une chaîne dans la même variable :

age = "John";
console.log(age);
console.log(typeof(age)); // string

JavaScript accepte cela sans avertissements ni erreurs. Le premier typeof indique "number" et le second indique "string" : une seule variable, deux types non liés au cours de son existence. Dans un petit script, cela n’a pas d’impact ; dans une grande base de code, tout code qui s’attend encore à recevoir un nombre depuis age se comportera incorrectement, loin de la ligne qui en est à l’origine.

TypeScript refuse ce type de code :

let data = 2;
data = "two"; // ❌ Error: Type 'string' is not assignable to type 'number'

Lorsque TypeScript voit let data = 2;, il déduit à partir de la valeur initiale que data a le type number, et il conserve ce type tout au long de la durée de vie de la variable. Ce comportement est appelé inférence de type. L’affectation de "two" sur la ligne suivante est donc rejetée par le compilateur avant même que le programme ne s’exécute.

Au lieu de compter sur l’inférence, vous pouvez définir vous-même le type :

let data: number = 2;

La partie : number est une syntaxe propre à TypeScript et n’existe pas en JavaScript. Pour la sécurité des types, les deux formes sont équivalentes ; la version annotée permet simplement de rendre l’intention visible à quiconque lit le code par la suite, y compris vous dans six mois.

Règle à retenir : JavaScript permet à une variable de changer de type autant de fois que vous le souhaitez, tandis que TypeScript définit une fois le type d’une variable, soit à partir de son annotation, soit à partir de sa première valeur, et l’impose par la suite.

Le piège chaîne+nombre

L’exemple précédent montre que TypeScript vérifie les types. Le suivant explique pourquoi c’est important.

JavaScript, lui, permet à nouveau sans problème qu’une variable qui contenait une chaîne soit écrasée par un nombre :

let message = "Hello";
let num = 2;
message = 20; // totally allowed in JS

Désormais, la surprise classique :

let result = "5" + 3;
console.log(result); // "53", not 8

On pourrait s’attendre à 8, car "5" a l’air numérique. Mais "5" est en fait une chaîne de caractères, et lorsque l’opérateur + rencontre une chaîne de chaque côté, JavaScript effectue une concaténation de chaînes plutôt qu’un calcul arithmétique. Il convertit 3 en texte et joint les deux, ce qui produit "53". JavaScript exécute cela sans problème, car il ne vérifie jamais les types à l’avance ; il fait simplement ce que le code indique littéralement au moment de son exécution.

Ce que fait TypeScript avec le même code

Voici la version en TypeScript, où les types sont explicitement indiqués :

let num1: string = "5";
let num2: number = 3;

Et l’addition elle-même :

let add = num1 + num2; // expected 8, but num1 is a string and num2 is a number
console.log(add); // actual result is 53

La valeur affichée reste 53. C’est là que de nombreuses explications se trompent, il est donc utile d’être précis. TypeScript autorise délibérément string + number, car la concaténation de texte et de nombre est une pratique courante et valide en JavaScript. Le vérificateur n’accepte pas cette expression ; il en déduit que add a pour type string.

L’erreur d’opérateur que vous avez peut-être vue ressemble à ceci :

Operator '+' cannot be applied to types 'string' and 'number'.

TypeScript affiche ce type de message pour des combinaisons d’opérandes qu’il considère sans sens, par exemple l’addition d’un boolean à un number, ou l’addition de valeurs dont les types n’ont rien en commun. Ce n’est pas ce qui se produit pour num1 + num2 ci-dessus.

Le point où TypeScript vous protège dans ce scénario est lorsque le résultat est utilisé comme un nombre. Si vous aviez écrit let add: number = num1 + num2;, le compilateur aurait indiqué que le type string ne pouvait pas être assigné au type number. De même, passer add à une fonction qui attend un number entraînerait une erreur. En pratique, c’est ainsi que le bug « 5 » + 3 est détecté dans une base de code typée : non pas au niveau du signe plus, mais partout où une chaîne de caractères accidentelle rencontre du code pour lequel un nombre était attendu.

Pourquoi les erreurs n’arrêtent pas votre programme par défaut

Il existe une deuxième subtilité. Même lorsque tsc détecte une erreur de type, il génère par défaut le fichier .js compilé. L’exécution de ce fichier avec node ne fait que lancer du JavaScript pur, rien de plus. Toutes les informations sur les types ont été supprimées, ne restant que la logique de exécution.

C’est là l’essence de TypeScript : il ne modifie pas le comportement de votre code en temps de exécution. Il signale les problèmes dans votre éditeur et lors de la compilation, avant même que vous ne lanciez quoi que ce soit. Si vous souhaitez que le compilateur refuse de générer du code en cas d’erreurs de type, activez cette option dans tsconfig.json:

{
  "compilerOptions": {
    "noEmitOnError": true
  }
}

Avec noEmitOnError activé, le code contenant des erreurs de type n’atteint jamais node, car tsc s’arrête sans écrire le fichier JavaScript. Dans la plupart des projets réels, on obtient le même effet en CI en exécutant tsc --noEmit comme étape distincte de vérification des types, ce qui entraîne l’échec de la compilation en cas d’erreur.

Le point clé : la valeur de TypeScript réside non pas dans un comportement différent en temps de exécution, car le TypeScript compilé est du JavaScript. Sa véritable valeur consiste à détecter les incohérences avant même que vous n’appuyiez sur « exécuter », vous donnant ainsi la possibilité de corriger des bugs qui, sinon, seraient intégrés silencieusement.

Annotations et inférence

Ces deux termes décrivent des méthodes opposées pour parvenir au même résultat. Prenons une déclaration annotée :

let age: number = 30;

Analysons-la morceau par morceau :

  • let est le mot-clé de déclaration
  • age est le nom de la variable
  • 30 est la valeur assignée
  • number est le type
  • : number, c’est-à-dire deux-points suivi du type, constitue l’annotation de type

: number, : string ou : boolean. L’inférence est l’inverse : on omet le type et TypeScript le déduit à partir de la valeur assignée.

let age = 30;        // inference — TS decides this is a "number" on its own
let city: string = "Delhi"; // annotation — you told TS explicitly

Tous deux offrent la même protection, car une fois le type défini, que ce soit par l’utilisateur ou par le compilateur, il reste fixe. Les annotations servent principalement à améliorer la clarté du code. Elles sont particulièrement utiles lorsque une variable est déclarée sans valeur initiale, car il n’y a rien à inférer, ainsi que pour les signatures de fonctions et les API publiques, où elles permettent de documenter l’intention. Pour les variables locales ayant une valeur initiale évidente, la plupart des équipes préfèrent laisser l’inférence faire le travail.

Types primitifs et non primitifs

Une façon utile de regrouper les types que vous rencontrerez dès le début :

  • Types primitifs tels que number, string, boolean, undefined et null ne stockent qu’une seule valeur à la fois.
  • Les types non primitifs, ou structurés, tels que les tableaux, les objets, les tuples, les fonctions, ainsi que les formes définies par des classes et des interfaces, combinent plusieurs valeurs, comme un tableau de scores ou un enregistrement d’utilisateur contenant un nom et une adresse e-mail.
  • Un primitif est une boîte contenant un seul élément ; un type structuré est une caisse qui contient de nombreuses boîtes.

    Deux autres constructions apparaissent souvent dans les listes destinées aux débutants à côté des primitifs, et elles sont abordées ci-dessous : any et les types union. Stricto sensu, aucun d’eux n’est un primitif. any constitue une issue de secours qui désactive les vérifications, tandis qu’un type union permet de combiner d’autres types. Ils se trouvent à côté des primitifs car ils sont généralement utilisés avec une seule valeur.

    Les types primitifs en pratique

    Les nombres

    Le type number de TypeScript couvre à la fois les entiers et les valeurs en virgule flottante. Il n’existe pas de types séparés int ou float comme dans de nombreux autres langages, car JavaScript stocke lui-même les nombres ordinaires sous forme de valeurs en virgule flottante de 64 bits.

    let age = 20;
    let price = 26.2;
    let big = 24757689;
    

    Pour les afficher et vérifier leur type avec typeof :

    console.log("Age :", age);
    console.log("price:", price);
    console.log(big);
    console.log(typeof(age)); // "number"
    

    Les nombres entiers, les décimaux et les valeurs très grandes sont tous simplement de type number, et typeof renvoie "number" pour chacun d’eux. Pour les entiers dépassant la plage de représentation sécurisée de ce format en virgule flottante, JavaScript propose un type séparé bigint.

    Chaînes de caractères

    Une chaîne de caractères est une séquence de caractères : un mot, une phrase ou tout texte. Vous pouvez écrire des littéraux de chaîne avec des guillemets simples, des guillemets doubles ou des backticks.

    let firstname: string = 'JOHN';
    let lastname: string = "SINGH";
    console.log("Hello", firstname, lastname); // Hello JOHN SINGH
    

    Lorsque plusieurs arguments séparés par des virgules sont passés à console.log, ils s’affichent séparés par des espaces, c’est pourquoi le nom complet est affiché. Cependant, une ligne similaire ne fait pas ce à quoi on pourrait s’attendre :

    // let greeting: string = "Hello", firstname, lastname; // ❌ this does NOT concatenate
    

    Dans une instruction let, une virgule sert à séparer plusieurs déclarations ; elle ne relie pas de texte. Cette ligne déclare trois variables distinctes, greeting, firstname et lastname, mais seule greeting reçoit une valeur. Si firstname et lastname existent déjà dans le même contexte, TypeScript signalera également qu’elles sont déclarées à nouveau.

    Ainsi, les virgules permettent d’afficher plusieurs valeurs, mais pour stocker une phrase combinée dans une variable, il faut utiliser un template literal : une chaîne de caractères entre backticks, avec des placeholders ${} pour les expressions.

    let firstname: string = 'JOHN';
    let lastname: string = "SINGH";
    let greeting: string = `Hello ${firstname} ${lastname}`;
    console.log(greeting); // Hello JOHN SINGH
    

    Les littéraux de template sont la méthode recommandée pour combiner du texte et des valeurs. Ils sont plus lisibles que des chaînes de concaténation avec +, et prennent également en charge des chaînes s’étendant sur plusieurs lignes.

    Booleens

    Un booleen ne peut avoir que deux valeurs possibles : true et false.

    let isStudent: boolean = true;
    let hasJob: boolean = false;
    

    Enregistrement de la valeur et vérification de son type :

    console.log("Is student?", isStudent); // true
    console.log(typeof (isStudent)); // "boolean"
    

    Les booleens contrôlent toutes les conditions dans votre code : les instructions if, les conditions de boucle, ainsi que tout endroit où une décision oui/non doit être prise.

    Null et undefined

    Tant null que undefined représentent l’absence de valeur, mais ils ont des significations différentes. Commencez par une variable déclarée mais jamais assignée :

    let emptyvalue;
    console.log(typeof emptyvalue); // "undefined"
    

    Une variable déclarée mais non assignée contient undefined, ce qui signifie que la variable existe mais qu’aucune valeur n’y a encore été stockée.

    Vous pouvez également assigner explicitement aux variables les types null et undefined :

    let emptyvalue: null = null;
    let value: undefined = undefined
    

    Puis comparez la manière dont chacune s’affiche, ainsi qu’une variable de type défini qui n’a pas été assignée de valeur :

    console.log(emptyvalue); // null
    console.log(value);      // undefined
    let price: number;
    console.log(price); // undefined - declared, but not yet assigned any value
    

    En termes simples, la différence est la suivante :

    • undefined signifie qu’une variable existe mais n’a pas de valeur ; c’est l’état vide par défaut.
    • null signifie qu’un développeur a délibérément mis la variable à « aucune valeur » ; c’est un vide intentionnel et explicite.

    : number restreint ce qu’une variable peut contenir par la suite ; elle ne fournit pas de valeur initiale. Lors de l’exécution, price affiche donc undefined. Notez cependant que, avec le mode strict activé, ce qui est la norme dans les projets modernes, TypeScript signale une erreur sur cette ligne console.log(price) car la variable est utilisée avant d’avoir été assignée. Ce contrôle constitue un autre exemple du compilateur détectant une erreur que le JavaScript pur permettrait.

    Dans le code quotidien, vous annoterez rarement une variable simplement comme null ou undefined ; ces exemples existent pour montrer le comportement des deux états vides. Ce qui est bien plus courant, c’est une union telle que string | null pour une valeur qui peut être absente.

    Le type any : désactiver le vérificateur

    Utilisez any rarement et de manière délibérée, car cela désactive précisément les vérifications pour lesquelles TypeScript a été conçu. Une variable any accepte tous les types de valeurs et peut être réaffectée à un type différent selon votre volonté, exactement comme en JavaScript pur.

    let value: any = "abcd";
    console.log(typeof value); // "string"
    

    Réaffectez-la d’abord à un nombre, puis à une valeur booléenne :

    value = 123;
    console.log(typeof value); // "number"
    value = true;
    console.log(typeof value); // "boolean"
    

    La variable passe de type chaîne de caractères à nombre, puis à booléen sans aucun problème, car l’annotation a indiqué au compilateur de ne pas la vérifier.

    Il existe des utilisations légitimes. Lorsque vous ne pouvez vraiment pas connaître un type à l’avance, par exemple avec des données externes imprévisibles, ou lors d’une migration incrémentale depuis JavaScript lorsque taper tout en une fois n’est pas pratique, any vous permet d’avancer. Cependant, en compter excessivement sur lui entraîne la perte de l’autocomplétion, des erreurs en temps de compilation et du filet de sécurité qui motivait à l’origine l’utilisation de TypeScript. Considérez-le comme un dernier recours, et non comme une habitude. Pour des données de forme inconnue, unknown est généralement le meilleur choix, car il vous oblige à vérifier la valeur avant de l’utiliser. Notre guide sur la substitution de any par des patterns sûrs en termes de types pour les cas courants aborde en détail ces alternatives.

    Types union : un ensemble contrôlé d’options

    type union permet à une valeur d’appartenir à l’un de plusieurs types spécifiques. Contrairement à any, il n’admet pas tout, seulement les types que vous listez. « Union » n’est pas un mot-clé ; c’est le nom du schéma, écrit avec le symbole de pipe |.

    let id: number | string | boolean;
    

    Affectation successive de chaque type autorisé :

    id = "123abc";
    console.log(id);
    id = 12;
    console.log(id);
    id = true;
    console.log(id);
    

    La variable id peut contenir un nombre, une chaîne de caractères ou un booléen, et rien d’autre. L’affectation d’un tableau tel que [1, 2, 3] est immédiatement rejetée, car les tableaux ne font pas partie de cette union. Cela vous offre de la flexibilité là où elle est nécessaire ; par exemple, les identifiants du monde réel arrivent parfois sous forme de nombres et parfois sous forme de chaînes, tout en maintenant des limites strictes quant à ce qui est autorisé. Avant d’utiliser une valeur issue de cette union de manière spécifique à un type, TypeScript exige que vous la restreigniez, par exemple au moyen d’une vérification typeof.

    Définition des types pour les fonctions

    Les fonctions regroupent une tâche unique en un code que vous pouvez réutiliser. Vous la définez une fois et l’appeliez chaque fois que vous en avez besoin, où que ce soit et aussi souvent que nécessaire.

    Une fonction sans paramètres

    La version la plus simple code ses valeurs de manière fixe :

    function sum() {
      console.log(10 + 20);
    }
    

    La définition d’une fonction ne la met pas en exécution. Rien ne se passe tant que vous ne l’appeliez pas :

    sum(); // calling the function → prints 30
    

    C’est justement cette séparation qui est importante : écrire la logique une seule fois et l’exécuter sur demande.

    Ajout de paramètres typés

    Les fonctions deviennent véritablement réutilisables une fois qu’elles acceptent des paramètres, des placeholders qui permettent au même code de fonctionner avec différentes valeurs à chaque appel.

    function sum(x: number, y: number) {
      console.log(x + y);
    }
    

    L’appel avec deux nombres :

    sum(10, 20); // prints 30
    

    Désormais, sum n’est plus lié à 10 et 20 ; sum(5, 7) ou sum(100, 1) fonctionnent tout aussi bien. Le : number après chaque paramètre constitue une annotation de type pour ce dernier, de sorte que TypeScript rejette une appelation telle que sum("hello", 20) avant même que le code ne s’exécute. C’est là que TypeScript se montre généralement utile : aux frontières des fonctions, lorsque l’une des parties d’un programme transmet des données à une autre.

    Cette version affiche toujours son résultat uniquement avec console.log. Elle ne renvoie rien au calleur, ce qui est important pour l’étape suivante.

    Déclaration d’un type de retour

    Une fonction qui renvoie son résultat est bien plus utile :

    function sum(x: number, y: number): number {
      return x + y;
    }
    

    Récupération et utilisation de la valeur renvoyée :

    let res: number = sum(10, 20);
    console.log(res); // 30
    

    Trois choses ont changé par rapport à la version précédente :

    • return x + y; renvoie la valeur à l’appelant au lieu de l’afficher à l’intérieur de la fonction.
    • Le : number après la liste des paramètres, dans (x: number, y: number): number, correspond à l’annotation de type de retour. Elle garantit que la fonction renverra toujours un nombre, et tout retour de chaîne de caractères provoquerait une erreur de compilation.
    • Puisque sum renvoie désormais une valeur, let res: number = sum(10, 20); peut la stocker pour une utilisation ultérieure : l’enregistrer, la transmettre à une autre fonction ou la combiner avec d’autres résultats.

    Une fonction qui renvoie son résultat peut être intégrée dans d’autres logiques ; une fonction qui ne fait que l’enregistrer est sans issue, car son output ne peut pas être réutilisé. TypeScript peut souvent déduire les types de retour, mais les écrire explicitement sur les fonctions exportées permet d’éviter tout changement accidentel dans ce que renvoie une fonction.

    Conclusion

    • JavaScript détermine les types à l’exécution et a une confiance totale en vous, ce qui est flexible mais peut entraîner des bugs silencieux tels que "5" + 3 produisant "53".
    • TypeScript définit le type de chaque variable via des annotations ou l’inférence et le vérifie à la compilation, avant que le code ne s’exécute.
    • TypeScript n’écarte pas toutes les expressions douteuses ; la concaténation de chaînes de caractères avec des nombres est autorisée, et la protection intervient lorsque la chaîne résultante est utilisée dans du code qui attend un nombre.
    • Par défaut, les erreurs de type n’arrêtent pas la génération du résultat ; utilisez noEmitOnError ou l’option tsc --noEmit dans les processus CI pour qu’elles bloquent la compilation.
    • Conservez any pour les cas où l’identité du type est réellement inconnue, préférez les unions et unknown pour plus de flexibilité, et ajoutez des annotations aux paramètres de fonction ainsi qu’aux types de retour lorsque les données franchissent des limites définies.

    Le résultat d’exécution du code JavaScript et TypeScript est identique. Ce qui change, c’est le moment où vous découvrez qu’il y a un problème : dans votre éditeur, et non devant vos utilisateurs.