Accueil Nos publications Blog Retour d’expérience sur TypeScript – Utilisation de librairies JavaScript – Partie 1 : Concepts clés

Retour d’expérience sur TypeScript – Utilisation de librairies JavaScript – Partie 1 : Concepts clés

Ts

Dans ce premier article nous avons vu la philosophie et les avantages qu’apportait TypeScript. Or les développements Front-Web actuels se font majoritairement à l’aide de librairies voire de frameworks JavaScript. Je vais donc poursuivre mon retour d’expérience sur la migration d’un site Web de JavaScript à TypeScript en détaillant l’utilisation combinée de Bootstrap, jQuery et Knockout.js.

Nous verrons cela en détail dans la seconde partie. Mais avant cela, il nous faut aborder certains concepts clés du langage TypeScript, ceci en fonction de la version employée, avant la 1.5, après la 2.0 ou entre les deux, et comment ils s’utilisent et se combinent.

I. Contextes

Lorsqu’on développe en TypeScript, il faut bien avoir en tête les deux « temps » qui correspondent à des contextes différents :

  • Le design time lorsqu’on code en TypeScript dans notre éditeur préféré est le contexte de développement.
  • Le run time lorsque le code JavaScript est exécuté est le contexte d’exécution. On distingue classiquement le côté client, dans un navigateur au sein d’une page HTML, et le côté serveur, avec Node.js, sans compter les environnements plus “exotiques” comme Electron1.
  Pour la suite de l’article, sauf mention contraire, nous nous placerons par défaut dans le contexte d’exécution côté client.

Entre les deux temps, il y a la phase de build. Cette phase comporte, entre autres :

  • la compilation2 des fichiers TypeScript
  • en autant de fichiers JavaScript ou en un ou plusieurs bundles,
  • minifiés ou non par « uglification »,
  • accompagnés ou non des fichiers « source maps » qui permettent de déboguer pas à pas les fichiers TypeScript i.e. faisant la passerelle entre les deux temps vus précédemment.

Dans le code TypeScript, toute la partie « typage des variables et des paramètres » sert juste au design time et ne se retrouve pas dans les fichiers JavaScript générés. Par contre, au run time, on constate bien que les variables ont le type spécifié ou interpolé côté TypeScript.

Ainsi, on peut considérer la conversion d’un fichier JavaScript en TypeScript comme une opération permettant entre autres de rendre explicite les types des variables au run time. D’où la problématique avec les librairies externes : lorsqu’une librairie externe n’existe qu’en JavaScript, la convertir en TypeScript n’est pas la chose la plus optimale à faire, à cause du coût et des risques de régression, sans compter les auteurs de la librairie pas forcément intéressés par TypeScript. C’est pourquoi TypeScript propose une solution de contournement moins coûteuse : les fichiers de définition. Ce qui nous amène à parler en premier lieu des références de fichier.

II. Référence de fichier

Dans un fichier TypeScript, pour indiquer que l’on utilise des éléments publics d’un autre fichier, il faut référencer ce fichier avec une ligne de commentaire spéciale appelée directive triple-slash, à placer en haut du fichier courant, en indiquant le chemin relatif du fichier source par rapport au fichier courant :


/// <reference path="utils.ts" />

// Reste du code TypeScript utilisant des éléments définis dans "utils.ts"...

Propriétés

  • Activation de l’IntelliSense
  • Utilisation pour la compilation :
    • Pour établir l’ordre de compilation,
    • Pour construire le bundle lors de l’usage de l’option de compilation --outFile.
  • Ressemblance avec les using C# / import Java
  • Transmission en cascade, de fichier en fichier : si A référence B qui référence C, A accède aussi à C.
  • Optionnel pour les types JavaScript de base tels que Date, Number et String : le compilateur référence par défaut ces types grâce à des fichiers de définition (expliqués plus bas) tels que lib.d.ts.

Problématiques

De ce fait, elles ne permettent pas d’établir facilement les dépendances d’un fichier, contrairement aux modules externes abordés plus bas. De même, cela ne permet pas d’être sélectif sur les éléments à importer et sur leurs noms : les scopes sont mélangés.

Cas d’utilisation

Les références de fichier sont plutôt utilisées dans ces cas de figure :

  • Entre fichiers de définition (que nous voyons tout de suite après au § III).
  • Dans les anciens projets n’ayant pas de fichier tsconfig.json, apparu avec la version 1.5 de TypeScript.
  • Pour référencer des fichiers de définition qui ne sont pas visibles par le compilateur du fait des options dans le fichier tsconfig.json.

Dans tous les cas, la gestion des références de fichier peut vite devenir pénible. Lorsqu’on n’a pas le choix, pour les fichiers de définition, l’astuce consiste à tous les référencer dans un fichier de définition global généralement nommé index.d.ts (ce que fait typings automatiquement, cf. plus bas) et à ne référencer que ce fichier :


/// <reference path="../typings/index.d.ts" />

A l’inverse, lorsqu’on le peut, mieux vaut utiliser un fichier tsconfig.json et le paramétrer de manière à pouvoir se passer de références de fichier, par exemple :


{
  "files": [
    "typings/index.d.ts"
  ]
}

Autres usages des directives triple-slash

Ce paragraphe fait référence à des éléments présentés par la suite. Le lecteur plus novice est invité à passer ce paragraphe pour y revenir à la fin.

Il existe d’autres types de directives triple-slash pour des usages plus particuliers :

  • Référence de type : pour référencer une dépendance vers une librairie depuis un fichier de définition.
  • Référencer aucune librairie JavaScript par défaut : pas d’usage lorsque les scripts s’exécutent dans un browser.
  • Personnaliser les modules AMD générés en sortie : nom, dépendances.

III. Fichier de définition

Cette section est un peu longue. Nous y abordons en détail à la fois les fichiers de définition et les outils pour installer ceux des librairies JavaScript : TSD, Typings et NPM.

Généralités

  • Il s’agit d’un type de fichier TypeScript spécial, avec l’extension ".d.ts".
  • Son but est de fournir les types TypeScript équivalents à ceux qui sont utilisés dans une librairie JavaScript. Il permet d’utiliser cette librairie dans les fichiers TypeScript comme si elle était écrite en TypeScript, en activant l’IntelliSense. Il s’agit donc du pendant « design time » de la librairie utilisée au « run time ».
  • On y trouve une syntaxe particulière, par exemple avec l’emploi du mot clé declare, et aucune implémentation n’est permise. A l’inverse, cette syntaxe est possible dans un fichier TypeScript standard.

Exemple basique

Considérons la librairie JavaScript suivante :


function showAlert(msg) {
  alert(msg);
}

Elle présente une seule méthode, showAlert prenant en paramètre une string et sans valeur de retour. On peut donc la typer ainsi :


declare function showAlert(msg: string): void;

A noter : le compilateur tsc a une option -d/--declaration qui permet de générer un fichier de définition à partir d’un fichier TypeScript standard.

Usage

On emploie un fichier de définition en l’indiquant dans une référence de fichier, ceci pour spécifier l’utilisation d’une librairie JavaScript dans un fichier TypeScript.

Exemple

Il s’agit d’utiliser jQuery pour sélectionner les éléments I. Voici les étapes suivies :

  1. On s’est procuré un fichier de définition pour jQuery selon une des méthodes décrites plus bas.
    → Le fichier obtenu est ./typings/browser/ambient/jquery/index.d.ts.
  2. On crée un fichier TypeScript à la racine du site et on l’édite.
  3. On place en haut du fichier une référence de fichier vers le fichier de définition de jQuery.
  4. On écrit la requête de sélection jQuery.
  5. On constate alors que la variable globale $ de jQuery est bien reconnue et présente le type JQueryStatic :
    IntelliSence TypeScript + jQuery

Attention

Lors de l’usage d’une librairie JavaScript, l’une des difficultés rencontrées par les développeurs JavaScript lorsqu’ils basculent sur TypeScript est la nécessité de faire « connaître » cette librairie au compilateur TypeScript en dépit du fait que tout marche bien côté JavaScript, la librairie étant bien reconnue, chargée et utilisée. C’est tout le but de cet article, de comprendre pourquoi, et comment faire, avec les deux temps « design time » (TypeScript) et « run time » (JavaScript).

A contrario, ce n’est pas parce qu’on a fait le nécessaire pour le compilateur TypeScript qu’il faut se passer de bien tout brancher côté JavaScript. Ainsi, l’usage d’un fichier de définition d’une librairie JavaScript n’indique pas au compilateur d’intégrer le code de cette librairie dans les fichiers JavaScript compilés en sortie. Il faut encore le faire manuellement, par exemple avec une balise script dans la page HTML :


<!DOCTYPE html>
<html lang="en" xmlns="https://www.w3.org/1999/xhtml">
<head>
  <meta charset="utf-8" />
  <title>jQuery example</title>
</head>
<body>
  <!-- ... -->
  <script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
  <script src="main.js"></script>
</body>
</html>

Récupération des fichiers de définition

Les fichiers de définition ont été mis en place progressivement pour les différentes librairies JavaScript par la communauté TypeScript. Actuellement, la majorité des librairies et de leurs plugins disposent de leurs fichiers de définition.

DefinitelyTyped est le premier référentiel mis en place pour centraliser les fichiers de définition. On en décompte quasiment 1900 actuellement ! Il est hébergé sur GitHub. Il a été créé par Boris Yankov, auteur de nombre de ces fichiers de définition.

On trouve également des fichiers de définition sur d’autres référentiels, hébergés par Bower et NPM. Ces référentiels ne se distinguent pas vraiment sur la liste des librairies référencées, mais plutôt sur le format des fichiers de définition et les outils CLI associés :

TSD

TSD est le premier outil ayant permis de gérer les fichiers de définition de DefinitelyTyped. Il est désormais deprecated, remplacé par typings que nous allons voir. Nous le mentionnons pour les projets qui l’utilisent encore.

Typings

L’utilitaire en ligne de commande typings fonctionne comme bower et npm :

  • Recherche avec la commande search
  • Initialisation d’un fichier de configuration avec init
  • Téléchargement/installation avec install
  • Enregistrement dans le fichier de configuration avec l’option --save
Particularités bower npm typings
Répertoire de téléchargement bower_components/ node_modules/ typings/
Fichier de configuration bower.json package.json typings.json

Les options proposées par Typings sont variées, en vue de couvrir tous les cas possibles. Cela le rend plus compliqué à utiliser. Sa documentation est détaillée mais mal organisée de mon point de vue. Pour ne rien arranger, le passage à la version 1.0 en mai 2016 s’est fait avec beaucoup de breaking changes. Cela rend obsolète un certain nombre d’articles publiés précédemment.

Typings permet ainsi de gérer :

  • Plusieurs catégories de définitions : external et global.
  • Plusieurs versions d’une même librairie.
  • Différentes sources.

En comparaison, TSD ne supportait que la dernière version des définitions relative à la version la plus élevée d’une librairie répertoriée sur DefinitelyTyped.

Catégories de définitions

On distingue deux catégories de définitions en relation avec différents types de librairie :

  • global : cela couvre les dépendances globales. Les définitions de types sont globales i.e. ajoutent des types au scope global ou fournissent des informations sur l’environnement de build (Browserify, WebPack) ou d’exécution (Electron, Node.js, window, module loaders) et même la version d’ECMAScript cible (présence de Array.prototype.map). Initialement, dans les versions 0.x de typings, on parlait d’ambient plutôt que de global mais cela prêtait à confusion, cf. ticket #343.
  • external : cela s’applique aux définitions sous forme de modules externes, notion que je vous présente plus bas dans l’article.

Typings promeut l’usage des définitions sous forme de modules externes. Il s’agit du type par défaut de la commande typings install. Pour les définitions globales, on utilise l’option --global. De même, la source par défaut est npm plutôt que dt (cf. sources ci-dessous).

Cette promotion des modules externes permet à Typings d’éviter des conflits de noms. Pour cela, Typings encapsule les modules externes dans un namespace de nom paramétrable, ce qui rend les définitions globales. Cela paraît déroutant de prime abord. En fait, cela permet de pouvoir consommer plusieurs versions d’une librairie à la fois, l’une utilisée par une librairie dépendante avec le nom officiel, l’autre utilisée directement par le programme sous forme d’alias. La documentation explique cela en détail pour les plus curieux.

Sources

Les sources correspondent à différents repositories (par défaut celui de typings) voire différents package managers et pouvant correspondre à une catégorie spécifique de définitions :

Source Repository Package Manager Type de librairies global
npm NPM idem
github GitHub Ex : Duo, JSPM
bitbucket Bitbucket x
jspm JSPM Incluent leurs définitions
bower Bower idem
common Inconnu
shared Fonctionnalité de librairie partagée
lib Fonctionnalité d’environnement partagée oui
env Environnements/IDE (Ex :
atom,
electron,
vscode)
oui
global Globale (window.<var>) oui
dt DefinitelyTyped habituellement

Exemple 1 : Knockout.js

Typings étant multi-sources, cela permet d’avoir pour une même librairie JavaScript différents fichiers de définition conçus différemment. Le cas mentionné parmi les exemples de la documentation est celui de Knockout.js. Côté JavaScript, Knockout expose un objet global ko et fonctionne avec un certain nombre de types tel que le type « Observable » en sortie de ko.observable(…). Cet objet et ces types peuvent être déclarés soit en global, soit à l’intérieur d’un namespace nommé ko ou exporté sous le nom ko. Ces différentes options sont disponibles selon la source spécifiée :


> typings search --name knockout                                                          
Viewing 3 of 3                                                                            

NAME     SOURCE … VERSIONS UPDATED                  HOMEPAGE                              
knockout global … 1        2016-05-16T17:19:30.000Z                                       
knockout dt     … 2        2017-01-05T10:11:47.000Z https://knockoutjs.com                 
knockout npm    … 1        2016-05-16T17:19:30.000Z https://www.npmjs.com/package/knockout
Source global

> typings install global~knockout --global
knockout@^3.4.0
`-- (No dependencies)

Extrait de ./typings/globals/knockout/index.d.ts :


declare module ko {
  [...]
  export interface Observable<T> [...]
  [...]
}

declare module "knockout" {
    export = ko;
}

Ce fichier combine les techniques :

  • Un namespace global ko correspondant à l’objet ko, ce qui est adapté à un usage global de Knockout tel que sur une page Web avec une balise script pour Knockout et une autre pour le script de l’application.
  • Un second namespace knockout exportant le premier, ce qui permet un usage de Knockout sous forme de module externe.
Source dt

> typings install dt~knockout --global
knockout
`-- (No dependencies)

Extrait de ./typings/globals/knockout/index.d.ts :


 [...]
interface KnockoutObservable<T> [...]
[...]
interface KnockoutStatic [...]
[...]
declare var ko: KnockoutStatic;

Dans ce fichier, les types sont globaux i.e. déclarés à la racine et non pas à l’intérieur d’un namespace et l’objet ko est déclaré sous la forme d’une variable.

Source npm

> typings install npm~knockout
knockout@^3.4.0
`-- (No dependencies)

Extrait de ./typings/globals/knockout/index.d.ts :


declare module 'knockout' {
  module ko {
    [...]
    export interface Observable<T> [...]
    [...]
  }
  export = ko;
}

Ce fichier déclare un namespace global knockout qui n’existe pas au run-time. Ce namespace sert juste à encapsuler les autres déclarations, dont celle d’un namespace privé ko qui est ensuite exporté avec la syntaxe pour les modules externes. Cela correspondant donc au cas d’usage en TypeScript 2 + modules externes + npm/@types présenté plus bas dans l’article.

Exemple 2 : jQuery

Avec l’exemple précédant de Knockout, on constate qu’il n’est disponible qu’en version 3.4.0 depuis les sources global et npm, voire sans version précise depuis la source dt. Voyons ce qu’il en est pour jQuery.

JQuery est disponible sur son CDN dans toutes ses versions, 1.x, 2.x et 3.x. Il serait possible théoriquement avec Typings d’avoir un fichier de définition pour chacune de ces versions. En pratique, on va voir que ce n’est pas le cas actuellement, loin de là.


> typings search --name jquery
Viewing 1 of 1

NAME   SOURCE HOMEPAGE           DESCRIPTION VERSIONS UPDATED
jquery dt     https://jquery.com/             2        2017-01-04T15:56:52.000Z

Il y a donc 2 versions disponibles. On peut obtenir le détail avec la commande suivante :


> typings view dt~jquery --versions
TAG                   VERSION … LOCATION                                                         …
1.10.0+20170104155652 1.10.0  … github:DefinitelyTyped/DefinitelyTyped/jquery/index.d.ts#e8761854…
1.10.0+20160929162922 1.10.0  … github:DefinitelyTyped/DefinitelyTyped/jquery/jquery.d.ts#c8a69b0…

Ainsi, seule la version 1.10.0 dispose d’un fichier de définition. Essayons quand même d’installer la dernière version de chaque version majeure :

Version 1.x

> typings install dt~jquery@1 --global
jquery
`-- (No dependencies)

→ La commande réussit mais ne donne pas la version exacte installée. On la trouve dans le fichier ./typings/globals/jquery/typings.json et il s’agit bien de la version 1.10.0 :


{
  "resolution": "main",
  "tree": {
    "src": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/e8761854cdadd8a71a91c228ad69bb0a20b7956e/jquery/index.d.ts",
    "raw": "registry:dt/jquery#1.10.0+20170104155652",
    "typings": "https://raw.githubusercontent.com/DefinitelyTyped/DefinitelyTyped/e8761854cdadd8a71a91c228ad69bb0a20b7956e/jquery/index.d.ts"
  }
}
Version 2.x

> typings install dt~jquery@2 --global
typings WARN deprecated 2016-05-10: "registry:dt/jquery@2" is deprecated (updated, replaced or removed)
jquery
`-- (No dependencies)

→ La commande réussit mais indique que le fichier est deprecated. La version en question indiquée dans le fichier ./typings/globals/jquery/typings.json est la version 2.0.0.

Version 3.x

> typings install dt~jquery@3 --global
typings ERR! message Unable to find "jquery" ("dt") in the registry.
typings ERR! message However, we found "jquery" for 1 other source: "dt"
typings ERR! message You can install these using the "source" option.
typings ERR! message We could use your help adding these typings to the registry: https://github.com/typings/registry
typings ERR! caused by https://api.typings.org/entries/dt/jquery/versions/3/latest responded with 404, expected it to equal 200

→ La commande échoue. Aucune version 3.x ne dispose d’un fichier de définition associé !

npm

A partir de TypeScript 2.0 uniquement !

La mise en place de la version 2.0 de TypeScript a concerné tant le langage que son écosystème. L’évolution de ce dernier s’est fait entre autres dans le but de simplifier l’installation et plus encore l’utilisation des fichiers de définition. Cela s’articule autour de deux éléments : les packages @types et de nouvelles options de compilation.

@types

Afin de ne pas multiplier les outils, l’idée est d’utiliser npm pour installer à la fois les librairies JavaScript et leurs fichiers de définition. Ces derniers peuvent être mis à disposition de deux façons3 :

Intégration au package npm de la librairie

Lorsque c’est le cas, c’est indiqué dans le fichier package.json avec l’une des options synonymes "types" ou "typings". Cependant ce n’est pas encore très répandu. C’est le cas pour Vue.js à partir de la version 2.0.0 de septembre 2016 :


{
  "name": "vue",
  "version": "2.1.10",
  "main": "dist/vue.runtime.common.js",
  "typings": "types/index.d.ts"
}
Publication sur npm/@types

Le domaine @types a été créé sur npm spécialement pour cela. La publication y est faite automatiquement lors de la validation du pull request sur DefinitelyTyped. L’exemple suivant permet de constater la similarité des commandes d’installation de bootstrap et son fichier de définition, au préfixe "@types/" près :


> npm install bootstrap@3
`-- bootstrap@3.3.7

> npm install @types/bootstrap@3
`-- @types/bootstrap@3.3.32
  `-- @types/jquery@2.0.40

Cela produit l’arborescence suivante :


node_modules
├─ @types
│  ├─ bootstrap
│  └─ jquery
└─ bootstrap

Notes :

  • Les fichiers de définition sont installés dans le répertoire node_modules/@types.
  • Il peut y avoir un écart de nom entre les packages de la librairie et le fichier de définition. On peut alors utiliser TypeSearch4 pour retrouver ce dernier.

Options de compilation

Le compilateur TypeScript 2+ prend automatiquement en compte les fichiers de définition trouvés dans les répertoires node_modules ou node_modules/@types situés à la racine du projet ou plus haut. Pour ces librairies, on n’a pas besoin de spécifier de référence de fichier.

On peut également indiquer d’autres répertoires où trouver les fichiers de définition. Pour cela, on utilise l’option de compilation "typeRoots", par exemple pour se brancher sur le répertoire typings si on utilise l’outil :


{
  "compilerOptions": {
    "typeRoots": ["./typings"]
  }
}

Une autre alternative consiste à référencer le fichier global ./typings/index.d.ts parmi les fichiers du projet via l’option "files".

Compatibilité avec bower et typings

Nous venons de voir que TypeScript 2 reste compatible avec typings. Heureusement puisqu’on a également vu que ce dernier propose plus de flexibilité, comme le choix du repository (autre que DefinitelyTyped) et du type de définition, global ou module.

Il en est de même pour la compatibilité avec Bower. Ainsi, dans un projet, on peut choisir d’installer les librairies avec bower et/ou npm, et les fichiers de définition avec npm/@types et/ou typings. C’est utile pour la compatibilité avec les projets existants. Cependant, lorsque cela est possible, la combinaison npm + npm/@types est la plus simple.

Compléter les définitions existantes

Dans certains cas, on est amené à compléter les définitions existantes. Cela peut arriver dans les cas suivants :

  • La librairie JavaScript n’a pas de fichier de définition. Cela arrive typiquement pour certains plugins anciens ou peu utilisés.
  • Le fichier de définition existe mais est incomplet ou correspond à une autre version de la librairie.
  • On souhaite personnaliser les librairies existantes, avec un plugin ou des méthodes maison.
  • On a besoin d’étendre les types natifs JavaScript, pour les passer à une version récente d’ECMAScript, en ajoutant des polyfills pour la compatibilité avec les navigateurs qui ne les supportent pas encore.

Voyons un exemple pour ces deux derniers cas :

jquery.tmpl.d.ts

→ Définition des types pour le plugin jQuery Templates datant de 2010 :


/// <reference path="../typings/browser/ambient/jquery/index.d.ts" />

interface JQuery {
  /**
   * Render the current JQuery template instance(s), using the specified data.
   * @param data:    The data to render. This can be any JavaScript type,
   *                 including Array or Object.
   * @param options: An optional map of user-defined key-value pairs.
   *                 Extends the tmplItem data structure, available to
   *                 the template during rendering.
   */
  tmpl(data?: any, options?: any, parentItem?: any): JQuery;
}

→ On notera que l’on étend l’interface JQuery déjà définie dans jquery/index.d.ts, ce qui est autorisé en TypeScript par défaut, sans même avoir besoin d’utiliser un mot clé. C’est la même chose pour la surcharge de méthode. En comparaison, en C# il faut utiliser respectivement partial et override.

number.extensions.d.ts

→ Ajout de la méthode Number.isInteger() :


interface NumberConstructor {
  /**
   * Indique si la valeur spécifiée est un entier.
   */
  isInteger: (value: any) => boolean;
}

if (!Number.isInteger) {
  Number.isInteger = (value: any): boolean => (
    typeof value === "number" &&
    isFinite(value) &&
    Math.floor(value) === value);
}

Alternative aux fichiers de définition

Même si ce n’est pas dans sa philosophie, TypeScript autorise à se passer de fichiers de définitions. On fait alors une déclaration minimaliste, basée sur le type any. Par exemple, pour utiliser la variable globale $ de jQuery, on peut la déclarer ainsi :


declare var $: any;
var $body = $('body');

Bien évidemment on ne dispose alors plus de l’IntelliSense, ceci en cascade : non seulement $ est de type any mais également $body. Il est donc conseillé de ne faire cela que temporairement, comme quand on est pressé par le temps.

IV. Modules

Généralités

En TypeScript comme en Javascript, on se situe par défaut dans un namespace global. Toute variable déclarée en dehors d’une fonction (qui peut en limiter le scope) ou sans l’un des mots clés var, let, const est globale. C’est l’un des mauvais côtés du JavaScript historique, dont on peut se prémunir par exemple avec la directive "use strict"; de ECMAScript 5 permettant de basculer en mode strict où l’affectation d’une variable non déclarée lève une ReferenceError.

Le module est l’un des moyens d’y remédier et de gérer entre autres :

  • Modularité pour structurer le code en unité logique et encourager la séparation des responsabilités et la réutilisabilité.
  • Encapsulation pour masquer, rendre privé des parties de code et n’exposer à l’extérieur que le nécessaire.
  • Limitation de la pollution du scope global et des conflits de nom de variables.

Les types de module en TypeScript sont issus de ceux en JavaScript. TypeScript en distingue deux catégories : les modules internes et les modules externes. La terminologie a été simplifiée avec la version 1.5 de TypeScript : les modules internes sont nommés « namespaces » et les modules sans autre précision désignent les modules externes.

Namespace TypeScript

Le namespace TypeScript est du sucre syntaxique au-dessus de variantes du pattern module en JavaScript, utilisant lui-même le pattern pattern IIFE.

Même si TypeScript permet de se passer d’utiliser ces patterns, mieux vaut les connaître car ils sont assez utilisés en JavaScript. Nous en verrons quelques éléments plus bas.

TypeScript propose deux mots-clés équivalents pour définir un namespace :

  • module est le mot-clé historique. On le retrouve encore dans des fichiers de définition.
  • namespace est le mot-clé désormais recommandé pour éviter toute confusion avec les modules « externes » que nous verrons plus bas.

Analogie avec C

Le terme namespace est le même qu’en C#. Les namespaces C# et TypeScript sont en effet très similaires :

  • Déclaration de namespace autour d’un bloc de code,
  • Possible hiérarchie de namespaces sur plusieurs niveaux depuis un namespace racine, par exemple Soat.Blog.Ts.
  • Namespaces indépendants des fichiers : on peut définir :
    • Plusieurs namespaces par fichier, même si ce n’est recommandé que dans les fichiers de définition.
    • Un namespace dans plusieurs fichiers, ce qui est à utiliser plutôt dans le cas des namespaces à plusieurs niveaux, un fichier par niveau et par nom.

L’analogie n’est probablement pas fortuite, TypeScript étant initialement le projet de Anders Hejlsberg qui est aussi le lead architect de C# chez Microsoft.

Fonctionnement

Voyons comment marchent les namespaces à la fois dans le TypeScript et dans le JavaScript résultant. Pour cela on utilise le « Playground TypeScript » qui est un compilateur en ligne.

A noter que connaître comment est compilé le TypeScript en JavaScript n’a pas qu’un intérêt intellectuel. C’est utile quand on doit déboguer directement en JavaScript. En effet, même si les outils de debug nous permettent de faire du pas à pas dans des fichiers TypeScript compilés avec leurs « source maps », dans certains cas cela ne suffit pas pour comprendre pourquoi cela bogue. On doit alors désactiver le suivi des source maps 5 et déboguer directement le code JavaScript.

C’est par exemple le cas lorsque l’on définit une classe héritant d’une autre, définie dans un autre fichier que l’on a omis d’intégrer à la page HTML. On remarque alors que l’erreur survient avec la fonction __extends servant à simuler l’héritage entre classes en JavaScript. Plus d’info sur cette fonction dans l’excellent livre en ligne TypeScript Deep Dive de Ali Syed Basarat, expert en TypeScript répondant à énormément de questions sur StackOverflow.

Niveaux

Partons du namespace suivant, avec un nom sur deux niveaux et deux variables internes, l’une publique et l’autre privée :


// TypeScript source
namespace Soat.Blog {
    export var messageVisible = "Hello!";
    var messagePrive = "Caché";
}

// JavaScript résultant
var Soat;
(function (Soat) {
    var Blog;
    (function (Blog) {
        Blog.messageVisible = "Hello!";
        var messagePrive = "Caché";
    })(Blog = Soat.Blog || (Soat.Blog = {}));
})(Soat || (Soat = {}));

On constate que chaque niveau dans le namespace correspond à :

  • Une variable de type objet déclarée en amont,
  • Le pattern module avec une expression passée en paramètre permettant de conserver la variable si elle existe déjà ou de l’initialiser.

Un namespace TypeScript sur N niveaux correspond donc à N objets JavaScript composés les uns dans les autres et dont celui racine est déclaré globalement.

  • Niveau 1 « Soat » : lignes 8, 9, 15
  • Niveau 2 « Blog » : lignes 10-14

Le pattern module est suffisamment souple pour permettre de définir les namespaces en plusieurs fois, y compris sur plusieurs fichiers :


namespace Soat {
    const baseUrl = "soat.fr";
    export namespace Blog {
        export var url = `blog.${baseUrl}`;
    }
}
namespace Soat.Blog {
    export function init() {
        window.location.href = url;
    }
}
Soat.Blog.init();

Visibilité

Avec les exemples précédents, on constate que :

  • Exemple 1 :
    • Les variables du namespace sont rendues publiques grâce au mot clé export, comme avec les modules ES6. Ces variables correspondent en JavaScript à des champs dans l’objet, cf. ligne 12. A noter que c’est différent pour les classes : les membres d’une classe étant publics par défaut, ce sont les membres privés qu’il faut indiquer, ceci avec le mot clé private.
    • Les variables privées sont compilées telles quelles, cf. ligne 13. Leur portée limitée à la fonction encapsulante les rend inaccessibles depuis l’extérieur.
  • Exemple 2 :
    • A l’intérieur d’un namespace ou d’un namespace imbriqué, on accède directement aux variables qui y sont définies, cf. ligne 4.
    • Depuis l’extérieur d’un namespace, on accède aux membres publics en spécifiant le namespace en préfixe, cf. ligne 12.

Namespace et référence de fichier

Contrairement aux namespaces C#, il n’est pas nécessaire « d’importer » un namespace pour s’en servir. Lorsque l’on fonctionne avec des références de fichier, il faut référencer le ou les fichiers source contenant les parties du namespace dont on a besoin. Sinon, il n’y a rien à faire.

On notera que le mot clé import existe, mais sert à définir un alias au namespace. Cela se traduit par la déclaration d’une variable en JavaScript. Point d’attention : cette variable peut être globale, comme on le constate avec le playground, ce qui peut provoquer des erreurs au run time :

TypeScript source

En référençant le namespace défini précédemment, on en crée deux alias, l’un global, l’autre dans un namespace test.


/// <reference path="soat.blog.ts" />

import sb = Soat.Blog;

namespace test {
    import blog = Soat.Blog;
    export var msg = blog.messageVisible;
}

JavaScript résultant


var sb = Soat.Blog;
var test;
(function (test) {
    var blog = Soat.Blog;
    test.msg = blog.messageVisible;
})(test || (test = {}));

Modules JavaScript

Le pattern module JavaScript ne permet pas de répondre à lui seul aux problématiques suivantes :

  • Gestion manuelle des dépendances : il faut charger les scripts dans le bon ordre.
  • Collision de namespaces : on peut toujours se retrouver avec des objets de même nom mais pas de même type.

En TypeScript cependant, les choses sont facilitées. Le compilateur prévient toute collision de namespace et permet de générer un bundle bien ordonné.

En JavaScript, ces problématiques ont conduit à la mise en place de différents types de modules successivement, CommonJS en 2009, puis AMD, UMD, jusqu’au support natif avec l’ES6.

CommonJS / Node

CommonJS gère bien les problématiques précédentes. C’est le format implémenté par Node.js6. Ce format marche bien avec tous les fichiers en local, qu’il charge de manière synchrone. Il est donc bien adapté aux scripts côté serveur. La syntaxe est assez élégante :

  • Déclaration : module.exports = myModule; à la fin du fichier pour définir la fonction ou l’objet à exposer.
  • Import :
    • En JavaScript : var myModule = require('myModule');
    • En TypeScript : import myModule = require('myModule');

AMD

AMD est un autre format de module répondant en plus à la problématique de chargement asynchrone des fichiers, mieux adapté à l’exécution dans un navigateur. Il est utilisé par le module loader require.js et le toolkit Dojo. La syntaxe est un peu plus verbeuse, tout se passant dans un appel à la fonction define où l’on spécifie :

  • le nom des éventuelles dépendances i.e. des autres modules utilisés dans le module courant,
  • la fonction callback exécutée lorsque le module est appelé, qui peut renvoyer les éléments exposés par le module et qui prend en paramètre les éventuels modules dépendants.
  • Exemples :
    • Module sans dépendance :
      javascript<br /> define([], function() {<br /> return {…};<br /> });
    • Module avec deux dépendances :
      javascript<br /> define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) { … });

UMD

UMD propose des patterns généraux permettant de définir des modules fonctionnant sur plusieurs environnements. On trouve sur leur repo github les différents cas possibles, dont celui qui permet de définir un module compatible AMD, CommonJS et les variables globales d’un navigateur. En voici un exemple :


(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['myModule', 'myOtherModule'], factory);
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = factory(require('myModule'), require('myOtherModule'));
    } else {
        // Browser globals => root is window
        root.returnExports = factory(root.myModule, root.myOtherModule);
    }
}(this, function (myModule, myOtherModule) {
    // Methods
    function hello() { }   // A public method because it's returned (see below)
    function goodbye() { } // A public method because it's returned (see below)
    function play() { }    // A private method

    // Exposed object
    return {
        hello: hello,
        goodbye: goodbye
    }
}));

ES6 / ES2015 / NativeJS

Les formats de module précédents ont un défaut majeur : ils ne sont pas natifs au langage. Ils ne fonctionnent que dans un écosystème spécifique au niveau :

  • Environnement d’exécution, par exemple dans NodeJS ou avec un module loader comme RequireJS,
  • Process de build, avec Gulp ou Grunt, et/ou un module bundler comme Browserify ou Webpack.

L’organisme ECMA de normalisation du JavaScript (entre autres) a proposé son propre type de module parmi d’autres propositions dans le standard ECMAScript 2015 anciennement nommé ES6.

On retrouve le même ratio 1 fichier = 1 module que CommonJS. Leur syntaxe est aussi similaire, celle de l’ES2015 étant même un peu plus élégante, plus précise et autorisant les deux types de chargement des dépendances : synchrone et asynchrone.

Exports

La déclaration des éléments « publics » du module se fait de manière explicite, en les préfixant avec le mot clé export, comme dans les namespaces TypeScript. Les autres éléments sont « privés » par défaut. Cela concerne n’importe quel type d’élément (classe, fonction, variable, autres types TypeScript : enum, interface…), quel que soit son emplacement dans le fichier.

Il y a deux types d’exports, les exports nommés et l’export par défaut, qui peuvent être utilisés dans un même module. Par contre, on ne peut avoir au plus qu’un seul export par défaut. On peut également définir en fin de module tous les éléments à exporter en les rassemblant dans un objet et en faire éventuellement l’export par défaut.

Imports

La consommation d’un module se fait avec le pattern import … from 'lib', en indiquant le chemin relatif d’accès à un module local ou le nom d’un package npm, comme avec le require de CommonJS. La syntaxe diffère selon que l’on cible l’export par défaut (1) ou les exports nommés, et pour ces derniers que l’on prenne tous les éléments (2) ou juste certains (3).

Exemple 1 : export par défaut


//------ MyClass.js ------
export default class { ... };

//------ main.js ------
import MyClass from 'MyClass';
let inst = new MyClass();

Exemple 2 : exports nommés et import global


//------ lib.js ------
export function square(x) {
    return x * x;
}
export function diag(x, y) {
    return Math.sqrt(square(x) + square(y));
}

//------ main.js ------
import * as lib from 'lib';
console.log(lib.square(11)); // 121

Note : le pattern import * as xxx from est appelé « pattern d’import sous forme de namespace », créant de facto un namespace TypeScript contenant tous les exports nommés.

Exemple 3 : imports sélectifs

La syntaxe se base sur la déstructuration d’objet :


//------ main.js ------
import { square, diag } from 'lib';
console.log(square(11)); // 121

Export par défaut à privilégier

Comme on le voit, la syntaxe pour définir et consommer un export par défaut est plus simple. Elle suggère même que l’export par défaut serait le corps principal du module, et les exports nommés ses annexes. C’est une volonté délibérée de la part des auteurs de l’ES2015, pour encourager l’usage de l’export par défaut devant celui des exports nommés, peut-être pour favoriser le principe de responsabilité unique (SRP) et avoir une seule classe par fichier.

L’élément exporté par défaut peut être anonyme étant donné que son nom sera défini au moment de l’import. A contrario, les exports nommés définissent le nom d’usage par défaut des éléments exportés. Il faudra utiliser le mot clé as au moment de l’import pour surcharger ce nom localement.

Remarques

TypeScript propose déjà le mot clé import avec les namespaces. Seulement son usage est facultatif. Il sert simplement à déclarer un alias pour un namespace. L’équivalent des imports ES2015 pour les namespaces TypeScript seraient les références triple-slash.

Pour plus de details sur les modules ES2015, vous pouvez consulter l’article ECMAScript 6 modules: the final syntax, by Dr. Axel Rauschmayer.

SystemJS

SystemJS est à la fois un module loader et un format de module. En tant que module loader, c’est celui qui offre la plus large compatibilité, prenant en charge tous les types de module : CommonJS, AMD, ES6 et fonctionnant dans les navigateurs comme dans NodeJS. Il est donc actuellement plébiscité.

Il propose sa propre syntaxe pour définir un module au moyen de la fonction System.register dont le fonctionnement est proche du define d’AMD. L’option --module=System du compilateur TypeScript permet de compiler un module (a priori ES2015 dans le cas d’usage classique) vers un module SystemJS.

Le chargement du module principal d’une application se fait au moyen de la fonction System.import :

Navigateur


<script src="systemjs/dist/system.js"></script>
<script>
  SystemJS.import('/js/main.js');
</script>

Node


var SystemJS = require('systemjs');

// loads './app.js' from the current directory
SystemJS.import('./app.js').then(function (m) {
  console.log(m);
});

Synthèse

Voici un tableau récapitulatif des avantages et inconvénients des différents modules JS et namespaces TS :

Critère Namespace CommonJS AMD UMD ES2015
Complexité de la syntaxe Simple Simple Moyenne Complexe Simple
Support natif Oui (*) Dans NodeJS Non Non Bientôt
Relation avec le système de fichiers N-N 1-1 N-N N-N 1-1
Import d’une dépendance : syntaxe Référence de fichier require("File") define(dependances, …) Mixte import
Import d’une dépendance : placement En haut du fichier N’importe où 1er argument de define En haut du fichier
Import sélectif Non Non Non Non Oui
Chargement asynchrone Non Non Oui Oui Oui
Analyse statique du code Oui Non Non Non Oui
Environnement cible Tous Serveur Client Tous Tous
Compatibilité avec NodeJS Oui (*) Oui Non Oui Oui
Compatibilité avec Browserify Oui Oui Non Oui Non
Compatibilité avec Webpack Oui Oui Non Oui Non
Compatibilité avec SystemJS Oui Oui Oui Oui Oui

(*) Après compilation du TS en JS

Modules et TypeScript

Interprétation

TypeScript est plus ou moins compatible avec les modules AMD et CommonJS. La documentation est plutôt élusive en la matière, hormis concernant la syntaxe similaire à CommonJS de export =, équivalent d’un export par défaut en ES2015, et sa contrepartie import X = require("mod").

En revanche, depuis la version 1.5 de TypeScript de juillet 2015, nous avons la reconnaissance des modules ES2015. Nous pouvons donc architecturer notre TypeScript en modules ES2015 qui seront correctement interprétés.

Compilation et rétrocompatibilité

Actuellement, il n’y a pas d’environnement d’exécution du JavaScript qui est compatible avec les modules ES2015. C’est pourquoi le compilateur TypeScript peut transformer le format des modules7, en lui spécifiant l’une des options de compilation suivantes :

  • -t/--target : en-dessous de "ES6", les modules ES2015 sont transformés en "CommonJS", ce qui est le comportement par défaut.
  • -m/--module : complète l’option target pour spécifier le format des modules après compilation, parmi "CommonJS", "AMD", "UMD", "System", "ES6" / "ES2015".

On peut alors envisager différentes options de contournement pour pouvoir écrire nos modules TypeScript au format ES2015 :

  • Pour NodeJS :
    1. Soit compiler les modules au format CommonJS,
    2. Soit utiliser SystemJS pour gérer les modules ES2015.
  • Pour les navigateurs :
    1. Soit compiler les modules au format AMD et utiliser RequireJS pour charger les modules,
    2. Soit utiliser SystemJS pour gérer les modules ES2015,
    3. Soit construire le(s) bundle(s) exécutable(s) directement dans le navigateur avec :
      • Soit Browserify en compilant les modules au format CommonJS,
      • Soit Webpack en compilant les modules au format CommonJS, AMD ou UMD.
        → Cette option est celle qui offre a priori les meilleurs temps de réponse et convient donc mieux à un environnement de production.

Namespace ou module ?

Les namespaces TypeScript ont pour eux leur simplicité et en inconvénient la pollution du namespace global et les dépendances possiblement difficiles à identifier. Les modules déclarent leurs dépendances mais dépendent actuellement d’un module loader ou bundler.

L’équipe TypeScript encourage l’emploi des modules, plus spécialement ceux ES2015, plutôt que les namespaces, dans l’optique de faire converger TypeScript et ECMAScript. Mais ils savent que, pour les petits projets, l’utilisation des namespaces peut s’avérer plus pertinente8. Par contre, la cohabitation de namespaces et de modules au sein d’un même codebase est compliquée. Les deux formats ne sont pas compatibles entre eux :

  • On peut certes exporter un namespace au sein d’un module mais il n’est alors plus global : les composantes du namespace définies dans d’autres fichiers ne sont pas prises en compte ; il faudrait recomposer le namespace après avoir importé ses composantes. D’autre part il est conseillé d’avoir le minimum de niveau dans les éléments exportés, d’avoir un module le plus plat possible, pour faciliter leur consommation. Cela incite à limiter l’export de namespaces au sein d’un module, le namespace ajoutant autant de niveaux d’imbrication qu’il a de niveaux.
  • Dans un namespace, on ne peut pas importer un module. Par exemple, la compilation du code ci-dessous produit l’erreur Import declarations in a namespace cannot reference a module. De même, déclarer un namespace en tant qu’export par défaut convertit le fichier en module et réduit le scope de ce namespace à ce module.
    javascript"<br /> namespace Soat.Blog {<br /> import * as $ from 'jquery';<br /> }

Le coût de migration namespaces → modules peut donc être rapidement conséquent. C’est donc à jauger au cas par cas.

V. Conclusion

Suivre les montées de version de TypeScript ?

Il s’agit de se demander s’il est souhaitable de mettre à niveau un codebase TypeScript en suivant la sortie des nouvelles versions de TypeScript. TypeScript évolue rapidement. Les versions qui apportent des changements majeurs se succèdent à une cadence rapide :

Nous avons vu que la version 2.0 permettait plus d’élégance dans la gestion des modules et des librairies externes. Cette version apporte également des évolutions majeures qui représentent des breaking changes lorsque les options associées sont activées :

  • noImplicitThis : rend le typage de this obligatoire dans les fonctions ; similaire et complémentaire à l’option noImplicitAny datant de la version 0.9.1 (août 2013) et qui rend le typage obligatoire lors de la déclaration d’une variable, d’un champ, etc.
  • strictNullChecks : rend l’usage de null et undefined explicite pour en limiter l’usage justement, afin de combler l’erreur à un milliard de dollars.

Même si les nouveautés de chaque version de TypeScript sont séduisantes, la mise à niveau d’un codebase est trop coûteuse. Il est plus pertinent de monter de version en gardant la compatibilité avec le code existant pour n’utiliser que certaines évolutions au besoin et d’utiliser les pleins potentiels de la dernière version de TypeScript du moment pour un nouveau projet.

C’est le cas chez mon client : nous avons commencé la migration du codebase JavaScript avec la version 1.4 et nous migrons de version de TypeScript tous les six mois environ. Nous avons gardé le système de namespaces dans notre projet et le bundling d’ASP.Net MVC.

Ecosystème TypeScript

L’écosystème JavaScript est déjà assez complexe. Celui de TypeScript l’englobant l’est donc encore plus. Cela peut être déroutant voire rédhibitoire pour certains. Passé ce cap, TypeScript permet en fait de mieux comprendre et mieux utiliser JavaScript.

Nous avons vu les éléments et concepts clés que sont les références de fichier, les fichiers de définition et leurs outils, les namespaces et les modules pour y voir plus clair et faire des choix avisés. Nous mettrons cela en application dans un prochain article avec l’utilisation concrète de librairies JavaScript depuis notre codebase TypeScript.

En complément, vous pouvez partir de l’article Making New TypeScript Projects a Breeze qui référence des guides pour commencer un projet TypeScript en fonction des autres technos du projet : ASP.NET 4, ASP.NET Core, Gulp, RequireJS, Webpack, Knockout, React.

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT


  1. Electron, précédemment nommé Atom-shell, est un shell basé sur Chromium permettant de construire des applications desktop en HTML, CSS et JavaScript. Les IDE Atom et VS Code se basent sur Electron. VS Code est même écrit directement en TypeScript ! 
  2. Pour les puristes, le terme exact n’est pas compilation mais transpilation. En effet, TypeScript est juste du sucre syntaxique au-dessus de JavaScript. Les deux langages ont le même niveau d’abstraction. Pour simplifier, nous utiliserons le terme compilation dans l’article. On notera d’ailleurs que l’outil pour compiler se nomme tsc, abréviation de TypeScript-Compiler, et pas tst
  3. Cf. Publishing | TypeScript Handbook 
  4. Cf. Consumption| TypeScript Handbook
  5. Dans Google Chrome, la désactivation des source maps se fait ainsi : Developer Tools [F12] > Settings [F1] > Preferences > Sources > Enable JavaScript source maps. 
  6. En fait il existe des différences entre les formats de modules CommonJS et Node. Comme il est courant de le faire, y compris dans la documentation TypeScript, nous emploierons le terme CommonJS pour désigner le format de module de Nodes.js. 
  7. Il en est de même avec d’autres fonctionnalités récentes d’ECMAScript. A défaut, on pourra utiliser en complément le transpileur Babel, en se référant au tableau de compatibilité « Kangax ». Plus d’info sur la page Gulp de la documentation qui explique comment combiner TypeScript et Babel. 
  8. Cf. Namespaces and Modules