Les modules en Typescript en 5 min

Dans un article précédent, j’avais eu l’occasion d’évoquer les points les plus essentiels de la syntaxe Typescript (cf. L’essentiel de la syntaxe Typescript en 10 min). Volontairement, cet article ne traitait pas des modules, des exports et des imports d’objets de façon à parler de ces sujets dans un article à part entière. La raison principale qui a motivé un article séparé est que les modules correspondent à un sujet plus complexe que les différents éléments de syntaxe Typescript.

En effet, la définition des modules en Typescript hérite de la complexité des modules en Javascript. Comme on a pu l’indiquer dans un article consacré aux modules Javascript, il y a différentes méthodes pour définir des modules en Javascript. Etant donné que ce langage ne prévoyait pas de façon native les modules, ces différentes méthodes correspondent à autant de solutions pour tenter de les implémenter. A partir d’ECMAScript 2015 (i.e. ES6), l’implémentation de modules en Javascript peut se faire de façon native. Même si ES2015 a rendu obsolètes les solutions précédentes d’implémentation de modules, on ne peut pas complétement les ignorer car beaucoup de code existant les utilise encore.

Les modules Typescript sont moins complexes qu’ils y paraissent

De même que pour Javascript avant ES2015, Typescript a aussi, de son coté, dû trouver des éléments syntaxiques pour définir et implémenter des modules. Quand Javascript a implémenté ES2015, la syntaxe de Typescript s’est naturellement rapprochée de celle d’ES2015. La conséquence de cette évolution est que la notion de module peut être implémentée d’une part, avec des éléments syntaxiques spécifiques à Typescript et d’autre part, avec une syntaxe similaire à celle d’ES2015.

En préambule, cet article expliquera quelques éléments nécessaires à la compréhension des modules en Typescript. Dans un 2e temps, on indiquera quelques solutions pour implémenter des modules avant et après ES2015.

Préambule

Avant de rentrer dans le détail des syntaxes Typescript permettant d’implémenter des modules, il faut savoir comment ces modules sont transpilés en code Javascript. Avant ES2015, la notion de module n’était pas native à Javascript, il a donc fallu trouver une façon d’implémenter des modules. La méthode a été d’utiliser le pattern module et des IIFE (i.e. Immediatly-Invoked Function Expression ou Expression de fonction invoquée immédiatement).

Pattern module

Sans rentrer dans les détails, le pattern module permet d’implémenter la notion de module en Javascript avant ES2015. Ce pattern utilise une IIFE (i.e. Immediatly-Invoked Function Expression ou Expression de fonction invoquée immédiatement) pour définir le contenu du module. Une IIFE est un bloc de code qui est exécuté tout de suite après avoir été parsé. Dans ce bloc de code, on peut ainsi définir ce qui sera le module.

Une IIFE s’écrit de cette façon:

var Module = (function() {  
    // Bloc de code à exécuter 
})();

Pour utiliser une IIFE pour définir un module, on peut écrire par exemple:

var Module = (function() {  
    var self = {};  
    function privateFunc() {  
        // ...  
    };  

    self.publicFunc = function() {  
        privateFunc();  
    };  

    return self;  
})();  

Cette écriture permet de définir une variable appelée Module qui va contenir des membres et des fonctions, ce qui correspond à la notion de module:

  • privateFunc() est une fonction privée.
  • publicFunc() est une fonction publique.

Pour utiliser ce type de module, on peut l’instancier et l’appeler de cette façon:

var moduleInstance = new Module();  
moduleInstance.publicFunc();  

Pour être compatible avec ES5, les modules Typescript sont transpilés en utilisant le pattern module (pour plus de détails sur la syntaxe Javascript du pattern module, voir Les modules en Javascript en 5 min).

Directive “Triple-slash”

Cette directive dans l’en-tête d’un fichier Typescript permet d’indiquer au compilateur une référence vers un autre fichier en indiquant son emplacement physique. Le chemin est relatif par rapport au fichier contenant la référence. Cette directive doit être placée dans l’en-tête du fichier pour être valable, elle est de type:

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

Ce type de directive est essentiellement utilisé pour référencer des fichiers Typescript externes à un projet. La plupart du temps, avec les fichiers de configuration tsconfig.json, elle n’est plus nécessaire puisqu’il est possible d’indiquer dans ce fichier les emplacements des fichiers à compiler.

Par exemple, pour ne pas utiliser de directives triple-slash, on peut utilser les éléments de configuration "files" ou "include" dans un fichier tsconfig.json:

{ 
    ... 
    "files": [ 
        "fichier1.ts", 
        "fichier2.ts", 
        "fichier3.ts" 
    ], 
    "include": [ 
        "src/**/*" 
    ], 
    ... 
} 

Plus de détails sur ces éléments de configuration dans la partie sur la résolution des modules.

Un exemple d’utilisation d’une directive triple-slash se trouve dans le dépôt Github suivant: typescript_modules/typescript_triple-slash.

Cet exemple permet d’illustrer des appels d’un module à l’autre:

  • le module NamespaceForModule1 dans example/module1.ts appelle le module NamespaceForModule2 dans other_modules/modules2.ts. La directive est:
    /// <reference path="../other_modules/module2.ts" />
    
  • le module NamespaceForModule2 appelle le module NamespaceForModule3 dans other_modules/modules3.ts. La directive est:
    /// <reference path="module3.ts" /> 
    

Pour exécuter cet exemple, il faut:

  1. Aller dans le répertoire typescript_triple-slash/example et exécuter:
    user@debian:~/typescript_modules/typescript_triple-slash% npm install
    
  2. Compiler en exécutant la commande:
    user@debian:~/typescript_modules/typescript_triple-slash% npm run build
    
  3. Exécuter le serveur de développement avec l’instruction:
    user@debian:~/typescript_modules/typescript_triple-slash% npm start
    
  4. Ouvrir la page http://localhost:8080 dans un browser
  5. Enfin, il faut afficher la console de développement.
Pour afficher la console de développement dans un browser

Pour tous les exemples présentés dans cet article, pour voir les résultats d’exécution, il faut afficher la console de développement:

  • Sous Firefox: on peut utiliser la raccourci [Ctrl] + [Maj] + [J] (sous MacOS: [⌘] + [Maj] + [J], sous Linux: [Ctrl] + [Maj] + [K]) ou en allant dans le menu “Développement web” ⇒ “Console du navigateur”.
  • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l’onglet “Console”. A partir du menu, il faut aller dans “Plus d’outils” ⇒ “Outils de développement”.
  • Sous EDGE: utiliser le raccourci [F12] puis naviguer jusqu’à l’onglet “Console”.

Le résultat de la compilation est de type:

Executed from module1.privateFunc() 
Executed from module2.privateFunc() 
Executed from module2.publicFunc() 
Executed from module3.privateFunc() 
Executed from module3.publicFunc() 
Executed from module1.publicFunc()
Suppression du cache du browser

Sachant que tous les exemples présentés dans cet article utilisent la même URL http://localhost:8080, il peut subvenir certaines erreurs pendant leur exécution. Ces erreurs peuvent être dues au cache utilisé dans les browsers, en particulier pour les fichiers Javascript. Pour éviter que d’anciennes versions du fichiers restées dans le cache ne soient exécutées à la place de nouveaux fichiers:

  • On peut forcer le rechargement de la page avec le raccourci [Ctrl] + [Maj] + R
  • On peut aussi supprimer les fichiers se trouvant dans le cache en exécutant le raccourci [Ctrl] + [Maj] + [Supp]

Implémenter des modules en Typescript

Il existe plusieurs méthodes pour implémenter des modules en Typescript:

  • Utiliser des namespaces: ils se rapprochent de la notion de namespace en C#. Ils sont transpilés en modules en Javascript.
  • Avant ES2015/ES6: il faut utiliser des formats pour étendre la syntaxe Javascript pour permettre l’implémentation de modules. Ces formats peuvent CommonJS, AMD (i.e. Asynchronous Module Definition) ou UMD (i.e. Universal Module Definition). Ces formats doivent être associés à des loaders qui permettent de charger les modules transpilés en Javascript au runtime.
  • Utiliser la syntaxe ES2015/ES6: à partir d’ES2015, Javascript prévoit des éléments de syntaxe pour implémenter nativement des modules. En Typescript, la syntaxe permettant d’implémenter ce type de module est très proche de celle en Javascript.

Namespaces

Les namespaces correspondent à une notion spécifique à Typescript pour implémenter des modules en Javascript. Les namespaces en Typescript sont très semblables aux namespaces en C#: il s’agit d’un découpage du code en bloc correspondant à des modules logiques.

Comme pour les namespaces en C#, les namespaces Typescript:

  • Ont des noms qui se définissent avec une hiérarchie du type <namespace1>.<namespace2>.<namespace3>, par exemple:
    mainNs.intermediateNs.innerNs 
    
  • Les blocs de code sont inclus dans les namespaces, par exemple:
    namespace nsName { 
        // Bloc de code 
    } 
    
  • Sont indépendants des fichiers Typescript: on peut utiliser un même namespace dans des fichiers différents. Les éléments dans le namespace seront, ainsi, regroupés.

Les namespaces sont une approche permettant d’implémenter des modules en Javascript mais elle n’est pas la seule. D’autres approches permettent d’implémenter cette notion.

Avant Typescript 1.5, les namespaces Typescript étaient appelés “internal modules“. Ce terme prête particulièrement à confusion puisqu’il facilite la confusion entre la notion de module Typescript et les modules à proprement parlé en Javascript. Il est important d’avoir en tête que cette notion de namespace n’est pas tout à fait semblable aux modules en Javascript.

Mot clé namespace et module sont strictement équivalents

Au niveau de la syntaxe Typescript, le mot clé namespace peut être remplacé par le mot clé module. Dans ce contexte, les 2 mots clé sont équivalents.

Un exemple d’implémentation de namespaces se trouvent dans le dépôt Github suivant: typescript_modules/typescript_namespace.

Cet exemple permet d’illuster des appels d’un module à l’autre. Les différents modules sont définis dans des namespaces différents.

Par exemple, le namespace module1Namespace qui se trouve dans le fichier module1.ts:

namespace module1Namespace { 
    function privateFunc() { 
        console.log("Executed from module1.privateFunc()"); 
    } 

    export function publicFunc() { 
        privateFunc(); 

        console.log("Executed from module1.publicFunc()"); 

        namespaceForModule2.publicFunc(); 
    } 
} 

Dans ce fichier, on importe le namespace module2Namespace se trouvant dans le fichier module2.ts de cette façon:

import namespaceForModule2 = module2Namespace; 

On peut ensuite appeler une fonction se trouvant dans le namespace module2Namespace en écrivant:

namespaceForModule2.publicFunc(); 

L’utilisation de la directive import est complétement facultative et sert uniquement à définir l’alias namespaceForModule2, on peut se contenter d’appeler directement:

module2Namespace.publicFunc(); 

De même que précédemment, pour compiler cet exemple:

  1. Il faut exécuter les instructions suivantes pour, respectivement, installer les packages npm, compiler et démarrer le serveur de développement:
    user@debian:~/typescript_modules/typescript_namespace% npm install && npm run build && npm start
    
  2. Ouvrir la page http://localhost:8080 dans un browser.
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui de l’exemple précédent.

On peut remarquer que le résultat de la compilation produit un module au sens Javascript en utilisant le pattern module. Par exemple, si on regarde le résultat de la compilation du contenu du fichier module1.ts dans le fichier public/module1.js:

 var namespaceForModule2 = module2Namespace; 
var module1Namespace; 
(function (module1Namespace) { 
    function privateFunc() { 
        console.log("Executed from module1.privateFunc()"); 
    } 

    function publicFunc() { 
        privateFunc(); 
        console.log("Executed from module1.publicFunc()"); 
        namespaceForModule2.publicFunc(); 
    } 

    module1Namespace.publicFunc = publicFunc; 
})(module1Namespace || (module1Namespace = {}));

Les namespaces peuvent être mergés

Les namespaces peuvent être mergés. Ainsi on peut définir des objets dans un même namespace réparti sur plusieurs fichiers Typescript à condition de ne pas avoir de collision dans le nommage des objets exportés.

Dans le répertoire typescript_modules/typescript_mergin_namespace se trouve un exemple d’un namespace défini sur plusieurs fichiers Typescript.

Par exemple, on définit le namespace module2Namespace dans le fichier module2.ts:

namespace module2Namespace { 
    function privateFunc() { 
        console.log("Executed from module2.privateFunc()"); 
    } 

    export function publicFunc() { 
        privateFunc(); 
        console.log("Executed from module2.publicFunc()"); 
        otherPublicFunc(); 
    } 
}

Et dans le fichier module3.ts:

namespace module2Namespace { 
    function privateFunc() { 
        console.log("Executed from privateFunc() in module3.ts"); 
    } 

    export function otherPublicFunc() { 
        privateFunc(); 
        console.log("Executed from otherPublicFunc() in module3.ts"); 
    } 
} 

A condition d’exporter des éléments avec le mot clé export, on peut utiliser plusieurs fichiers pour définir un namespace. Les fichiers module2.ts et module3.ts seront transpilés en 2 fichiers Javascript qui définissent un seul module Javascript.

Pour compiler, il faut exécuter:

user@debian:~/typescript_modules/typescript_merging_namespace% npm install && npm run build

Parmi les fichiers générés, on obtient module2.js:

var module2Namespace; 
(function (module2Namespace) { 
    function privateFunc() { 
        console.log("Executed from module2.privateFunc()"); 
    } 

    function publicFunc() { 
        privateFunc(); 
        console.log("Executed from module2.publicFunc()"); 
        module2Namespace.otherPublicFunc(); 
    } 

    module2Namespace.publicFunc = publicFunc; 
})(module2Namespace || (module2Namespace = {})); 

Et module3.js:

var module2Namespace; 
(function (module2Namespace) { 
    function privateFunc() { 
        console.log("Executed from privateFunc() in module3.ts"); 
    } 

    function otherPublicFunc() { 
        privateFunc(); 
        console.log("Executed from otherPublicFunc() in module3.ts"); 
    } 

    module2Namespace.otherPublicFunc = otherPublicFunc; 
})(module2Namespace || (module2Namespace = {})); 

On remarque que ces fichiers permettent de définir un même module nommé module2Namespace.

Pour exécuter cet exemple, il faut exécuter:

user@debian:~/typescript_modules/typescript_merging_namespace% npm start 

Il faut ensuite ouvrir un browser à l’adresse http://localhost:8080 et afficher la console de développement.

Implémenter des modules avant ES2015

Les namespaces Typescript ne correspondent pas tout à fait aux modules Javascript. Ils correspondent à un découpage logique du code qui peut être séparé en plusieurs fichiers. A l’opposé les modules Javascript sont découpés par fichier: un fichier correspond à un module le plus souvent.

Avant Typescript 1.5, namespace et module s’appelaient différemment:

  • Les namespaces s’appelaient “internal modules” et
  • Les modules s’appelaient “external modules“.

Même si les notions de namespace Typescript et de module sont différentes, après compilation du code, les namespaces Typescript sont transpilés en module Javascript en utilisant le pattern module. En effet, avant ES2015, Javascript ne gérait pas de façon native la notion de module. Le pattern module est un moyen de contournement pour définir des modules. Pour implémenter ces modules, on peut s’aider de formats qui vont apporter des mot-clés pour enrichir le langage Javascript et permettre une implémentation plus facile des modules sans écrire explicitement le pattern module.

En Typescript, on bénéficie de ces formats et on peut, comme en Javascript, les utiliser pour implémenter des modules Javascript avant ES2015. Au runtime, pour charger les modules en utilisant le format, il faut s’aider de loaders. Les formats les plus connus sont CommonJS, AMD (i.e. Asynchronous Module Definition) et UMD (i.e. Universal Module Definition). Les loaders les plus connus sont RequireJS (utlisant le format AMD), SystemJS (qui permet de gérer plusieurs formats).

CommonJS + SystemJS

CommonJS et SystemJS sont respectivement un format et un loader permettant d’implémenter les modules avant ES2015. Il existe d’autres formats comme AMD ou UMD. Un exemple d’implémentation de module en utilisant CommonJS se trouve dans le dépôt Github suivant: typescript_modules/typescript_commonjs. Cet exemple permet d’illustrer des appels entre modules.

Comme en Javascript, CommonJS permet de définir un format pour enrichir le langage avec une fonction qui va permettre d’effectuer des références vers un autre module. La fonction utilisée pour inclure des modules provenant d’un autre fichier est require.

Par exemple, si on écrit:

var moduleInFile = require('./file.js');  

On définit une variable locale contenant le module se trouvant dans le fichier file.js. On peut directement utiliser les éléments se trouvant dans le module en les préfixant avec moduleInFile.

CommonJS sert de format pour la compilation. Pour l’exécution du code, il est nécessaire d’utiliser un loader qui va interpréter la syntaxe définie par le format CommonJS et charger les modules Javascript. SystemJS est un loader qui permet d’interpréter le format CommonJS.

Pour plus de détails sur CommonJS et SystemJS, voir CommonJS + SystemJS.

Dans le cas de notre exemple, si on regarde le code du module se trouvant dans le fichier module1.ts:

import importedModule2 = require('./module2'); 

function privateFunc() { 
    console.log("Executed from module1.privateFunc()"); 

    importedModule2.publicFunc(); 
} 

export function publicFunc() { 
    privateFunc(); 

    console.log("Executed from module1.publicFunc()");
} 

On importe le module2 en utilisant la fonction require du format CommonJS:

import importedModule2 = require('./module2'); 

Puis on utilise l’alias défini pour appeler une fonction se trouvant dans le module2:

importedModule2.publicFunc(); 

Pour compiler, il faut exécuter:

user@debian:~/typescript_modules/typescript_commonjs% npm install && npm run build

Après compilation, la syntaxe produit des modules compatibles avec le format CommonJS:

"use strict"; 
Object.defineProperty(exports, "__esModule", { value: true }); 
var importedModule2 = require("./module2"); 
function privateFunc() { 
    console.log("Executed from module1.privateFunc()"); 
    importedModule2.publicFunc(); 
} 

function publicFunc() { 
    privateFunc(); 

    console.log("Executed from module1.publicFunc()"); 
} 

exports.publicFunc = publicFunc; 

Pour exécuter ce code, il faut effectuer quelques ajouts dans la page HTML principale en important systemJS puis en le configurant:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>Test module Typescript with /// (triple-slash)</title> 
        <script src="node_modules/systemjs/dist/system.js"></script>  
        <script> 
            // Le code suivant correspond à de la configuration  
            SystemJS.config({  
                meta:{     
                    format: 'cjs'  // cjs correspond à la configuration pour le format commonjs  
                },     

                packages: { 
                "/public": { defaultExtension: "js" }   // pour charger par défaut les fichiers avec l'extension JS 
                }
            });  

            SystemJS.import('public/index.js'); 
        </script>
    </head>    
    <body>
    </body> 
</html> 

Pour exécuter cet exemple, il faut exécuter la commande:

  1. user@debian:~/typescript_modules/typescript_commonjs% npm start
    
  2. Se connecter avec un browser à l’adresse http://localhost:8080
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Modules ES2015/ES6

Il est possible d’utiliser la syntaxe ES2015 dans le code Typescript pour définir des modules Javascript. Les modules Javascript ainsi compilés ont les mêmes caractéristiques que des modules ES2015 classiques. Ils sont supportés nativement par Node.js et par les browsers à partir d’une certaine version:

  • Chrome à partir de la version 63.
  • Firefox à partir de la version 60,
  • Edge à partir de la version 16,
  • Safari à partir de la version 11,
  • Node.js à partir de la version 10.

Pour plus d’informations sur les compatibilités des browsers:

Le code Javascript comportant des modules ES2015 s’exécute de plusieurs façons:

  • Exécuter le code Javascript sur un serveur Node.js: à partir de la version 10, Node.js gère nativement le chargement de modules au format ES2015.
  • Il est aussi possible d’associer un loader comme RequireJS ou SystemJS qui va charger lui-même les modules.

Les propriétés les plus importantes à avoir en tête avec les modules ES2015 sont les suivantes:

  • Par défaut, tous les modules ES2015 sont privés.
  • Un fichier Javascript correspond à un module.
  • Pour exposer des éléments comme des variables, des fonctions ou des classes à l’extérieur d’un module, il faut utiliser le mot-clé export.

Exporter un module

La syntaxe Typescript utilisant des modules est semblable à celle en Javascript. Il faut exporter explicitement les éléments pour les utiliser à l’extérieur d’un module.

Pour exporter, on utilise le mot-clé export.

Par exemple:

export function func1() {  
    // ...  
}  

Dans cet exemple, on exporte une fonction à l’extérieur du module.

D’autres formes de syntaxe sont possibles pour exporter plusieurs éléments du module en une fois:

function func1() {  
    // ...  
}  

function func2() {  
    // ...  
}  

export { func1, func2 };

On peut aussi indiquer l’instruction export avant la déclaration des éléments à exporter:

export { func1, func2 };

function func1() {  
    // ...  
}  

function func2() {  
     // ...  
}  

On peut indiquer un alias lors de l’export:

export { func1 as exporterFunc1 };  

function func1() {  
    // ...  
}

Importer un module

L’import d’un module correspond à importer les éléments exportés par ce module dans un autre.

Pour importer tous les éléments exportés d’un module dans une variable, on peut utiliser la syntaxe:

import * as moduleInFile from './file'; 

Dans ce cas, la variable est moduleInFile, on peut l’utiliser directement pour accéder aux éléments du module, par exemple:

moduleInFile.func1();  

On peut importer un élément spécifique et non pas tous les éléments exportés du module en précisant les éléments à importer à partir de leur nom, par exemple:

import { func1 as funcFromOuterModule, func2 } from './file';  

func1 as funcFromOuterModule permet de renommer le nom de l’élément importé. Cette déclaration est facultative, ainsi elle n’est pas utilisée pour func2.

Dans ce cas, on peut appeler directement les fonctions:

func2();  
funcFromOuterModule();  

Export par défaut

L’export d’un élément par défaut permet d’indiquer l’élément qui sera importé dans un module si ce dernier ne précise pas ce qui doit être importé. Ainsi, au moment d’importer l’élément d’un module, il ne sera pas nécessaire de préciser le nom de l’élément à importer, l’élément par défaut sera le seul élément importé même si le fichier comporte d’autres éléments.

Par exemple:

function func1() {  
    // ...  
}  

function func2() {  
    // ...  
}  

export default func1;  

Au moment d’importer, le seul élément importé sera l’élément exporté par défaut:

import func1 from "file";   

L’import ne comporte pas d’accolades. C’est l’élément par défaut qui est importé.

L’élément importé par défaut peut être utilisable directement:

func1();  

Export nommé

On peut nommer un export de façon à modifier le nom de l’élément qui est exporté.

Par exemple, dans un premier temps on exporte un élément en le renommant:

function func1() {  
    // ...  
}  

function func2() {  
    // ...  
}  

export default func1;  
export var finalFunc = func2;

En plus de l’élément par défaut, on décide de renommer un élément exporté.

A l’import, il faut indiquer le nouveau nom de l’élément:

import { finalFunc } from "./file";  

On peut directement utilisé le nouveau nom de l’élément:

finalFunc();  

Dans cet exemple, si on souhaite importer l’élément par défaut, on peut écrire:

import func1, { finalFunc } from "./file";  

Configuration du compilateur

Il existe une option de compilation qui permet d’indiquer la syntaxe à utiliser pour les modules dans le code Javascript et pour générer du code Javascript avec une syntaxe différente. L’option module permet d’indiquer le format à utiliser pour la génération des modules:

{ 
    "compilerOptions": { 
        "module": "es6"
    } 
} 

Les valeurs possibles sont "CommonJS", "AMD", "UMD", "ES6", "ES2015", "ESNEXT" ou "None". Si on utilise "None", on ne peut pas déclarer des modules dans le code Typescript. Il ne faut pas confondre ce paramètre avec target qui indique la version d’ECMAScript cible de compilation. La configuration module ne concerne que le format utilisé pour les modules.

Ainsi, si on utilise l’option:

  • "es6" ou "es2015": il faut préciser l’extension ".js" dans les déclarations import dans les fichiers Typescript, par exemple:
    import { finalFunc } from "./file.js";
    

    Cette déclaration sera transpilée tel quel dans le fichier Javascript. Au runtime, pour que le browser ou Node.js puisse trouver le fichier à importer, il faut qu’il comporte l’extension du fichier Javascript.

  • "CommonJS": le format généré sera CommonJS. Au runtime, il faut associer ce format à un loader comme par exemple SystemJS.
  • "AMD": le format généré sera AMD. De même au runtime, il faut associer ce format à un loader comme par exemple RequireJS.
  • "UMD": le format généré sera UMD (i.e. Universal Module Definition). Ce format permet de générer compatible avec plusieurs formats comme AMD et CommonJS.

On peut aussi préciser le format au moment d’exécuter le compilateur:

tsc --module <nom du format>

Par exemple:

tsc --module es6

Exemple avec la syntaxe ES2015/ES6

Un exemple d’implémentation de modules en utilisant la syntaxe ES2015/ES6 se trouvent dans le dépôt Github suivant: typescript_modules/typescript_es6. Cet exemple permet d’illustrer des appels d’un module à l’autre en utilisant la syntaxe ES6.

Pour exécuter cet exemple:

  1. Il faut exécuter les instructions suivantes pour, respectivement, installer les packages npm, compiler et démarrer le serveur de développement:
    user@debian:~/typescript_modules/typescript_es6% npm install && npm run build && npm start
    
  2. Ouvrir la page http://localhost:8080 dans un browser.
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Exemple avec la syntaxe AMD

Un exemple d’implémentation de modules générés au format AMD se trouve dans le dépôt Github suivant: typescript_modules/typescript_es6_amd. Cet exemple permet d’illustrer la génération des modules au format AMD.

Pour exécuter cet exemple, il faut installer les packages en exécutant les commandes:

  1. Il faut exécuter les instructions suivantes pour, respectivement, installer les packages npm, compiler et démarrer le serveur de développement:
    user@debian:~/typescript_modules/typescript_es6_amd% npm install && npm run build && npm start
    
  2. Ouvrir la page http://localhost:8080 dans un browser.
  3. Afficher la console de développement.

Le format AMD ajoute une fonction qui n’existe pas dans la syntaxe Javascript: define. Cette fonction comporte 2 paramètres:

  • Une tableau de dépendances: ce sont les fichiers nécessaires à l’exécution de la fonction.
  • Une fonction: cette fonction est le module à définir.

On peut voir un exemple d’utilisation de la fonction define en regardant le résultat de la compilation, par exemple dans le répertoire typescript_es6_amd/build/index.js:

define(["require", "exports", "./module1.js"], function (require, exports, module1_js_1) { 
    "use strict"; 
    Object.defineProperty(exports, "__esModule", { value: true }); 
    var Startup = /** @class */ (function () { 
        function Startup() { 
        } 

        Startup.main = function () { 
            module1_js_1.publicFunc(); 
            return 0; 
        }; 

        return Startup; 
    }()); 

    Startup.main(); 
}); 

Avec ce format, le loader utilisé est RequireJS. Il est inclus dans le code HTML de la page principale index.html:

<script data-main="app" src="node_modules/requirejs/require.js"></script> 

L’attribut data-main="app" correspond au point d’entrée de l’application qui est à un fichier Javascript app.js contenant la configuration de requireJS.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Génération de code compatible avec ES5

Comme on a pu le voir pour l’exemple utilisant le format AMD, on a utilisé dans le code Typescript une syntaxe ES2015. Pourtant les modules ont été générés au format AMD qui compatible avec ES5. Il en est de même si on génère les modules en utilisant le format CommonJS.

Utilisation de “bundlers”

Les solutions présentées jusqu’içi ont un gros inconvénient: elles nécessitent d’inclure tous les fichiers Javascript générés dans le fichier HTML principal. Dans les exemples, on déclare les scripts Javascript correspondant aux différents modules de cette façon:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>Test module Typescript ES6</title> 
    </head>    
    <body> 
        <script src="module3.js" ></script> 
        <script src="module2.js" ></script> 
        <script src="module1.js" ></script> 
        <script src="index.js" ></script>
    </body> 
</html> 

Ce type de déclaration peut être difficile à maintenir à l’échelle d’un grand projet.

Une solution pour palier à ce problème peut être d’utiliser des bundlers qui vont générer un fichier Javascript unique en incluant toutes les dépendances au moment de la compilation. Le code produit sera compatible ES5 et utilisable sur tous les browsers supportant ES5. Le gros inconvénient des bundlers est qu’ils vont générer un seul fichier contenant tout le code Javascript. Dans le cas d’une grosse application et si l’application comporte beaucoup de code Javascript, le temps de chargement du fichier bundle peut prendre du temps et ralentir le chargement de la page principale.

Les bundlers les plus connus sont Browserify ou webpack.

Exemple d’utilisation de webpack avec la syntaxe ES2015

Un exemple d’implémentation de modules en utilisant le bundler webpack se trouve dans le dépôt Github suivant: typescript_modules/webpack_es6. Cet exemple permet d’illustrer la génération d’un bundle contenant des modules implémentés au format ES2015.

Pour utiliser le bundler webpack, on a installé les packages webpack et ts-loader. Le package ts-loader permet à webpack de s’interfacer avec le compilateur Typescript. Pour exécuter webpack avec un serveur de développement, on a installé le package webpack-dev-server. Pour effectuer ces installations, on a exécuté la commande suivante:

npm install --save-dev webpack ts-loader webpack-dev-server 

Le fichier de configuration de webpack qui se trouve dans typescript_modules/webpack_es6/webpack.config.js permet d’indiquer qu’on souhaite utiliser le loader ts-loader et que le résultat de la compilation se fasse dans le fichier public/app.js. Le contenu de ce fichier est:

const path = require('path'); 

module.exports = { 
    entry: './index.ts', 
    module: { 
      rules: [ 
        { 
          use: 'ts-loader', 
          exclude: /node_modules/ 
        } 
      ] 
    }, 
    resolve: { 
      extensions: [ '.tsx', '.ts', '.js' ] 
    }, 
    output: { 
      filename: 'app.js', 
      path: path.resolve(__dirname, 'public') 
    } 
}; 

Pour lancer la compilation avec webpack en utilisant le fichier de configuration, on peut exécuter la commande suivante:

webpack ./webpack.config.js --mode development 

Enfin pour utiliser le bundle, il suffit de rajouter le script correspondant au bundle dans le fichier HTML principal:

<!DOCTYPE html> 
<html lang="en"> 
    <head> 
        <meta charset="UTF-8">  
        <title>Test module TS with webpack</title> 
    </head>
    <body>
        <script src="app.js" ></script>
    </body> 
</html> 

Pour lancer l’exécution avec le serveur de développement de webpack, on peut exécuter la commande suivante:

webpack-dev-server ./webpack.config.js --content-base ./public --mode development 

Plus simplement dans le cadre de cet exemple, on peut aussi:

  1. Exécuter les commandes suivantes qui effectuent toutes ces opérations:
    user@debian:~/typescript_modules/webpack_es6% npm install && npm run build && npm start
  2. Charger la page http://localhost:8080 sur un browser.
  3. Afficher la console de développement.

Le résultat de l’exécution est semblable à celui des exemples précédents.

Exemple d’utilisation de webpack avec la syntaxe “namespace”

Les différentes syntaxes d’implémentation de modules sont possibles avec webpack. On a montré précédemment un exemple avec le format ES2015. Il est facile de transposer cet exemple en utilisant le format namespace spécifique à Typescript.

Un exemple d’implémentation avec la syntaxe des namespaces se trouve dans le dépôt Github suivant: typescript_modules/webpack_es6_namespace. L’exemple est le même que précédemment, à la différence de l’utilisation de namespaces pour déclarer des modules (la syntaxe générale des namespaces a été explicitée plus haut).

Comment le compilateur parcourt les modules ?

Pour trouver les modules suivant les références faites avec la directive triple-slash ou avec import, le compilateur effectue une étape de résolution. Cette résolution des modules se fait en partie en fonction de la configuration. De nombreux éléments de configuration peuvent avoir un impact sur la façon de réaliser cette résolution.

D’une façon générale, le compilateur Typescript tsc inclue dans son étape de compilation tous les fichiers qui se trouvent dans le répertoire du fichier de configuration tsconfig.json.

Avant de compiler ces fichiers, le compilateur va résoudre les dépendances de modules en fonction des directives import et triple-slash.

Paramètres de compilation “files”, “include” et “exclude”

Le comportement du compilateur lors de la résolution de modules peut être modifié par l’utilisation de certains paramètres dans le fichier tsconfig.json:

  • "files": ce paramètre permet d’indiquer les chemins absolus ou relatifs des fichiers à compiler:
    { 
       "compilerOptions": {}, 
       "files": [ 
           "file1.ts", 
           "file2.ts", 
           "file3.ts"
       ] 
    }
    

    Quand ce paramètre est présent, le compilateur ne va pas parcourir tous les fichiers .ts/.tsx/.d.ts présents dans le répertoire du fichier de configuration tsconfig.json mais seulement les fichiers précisés et leur références.

  • "include": permet d’indiquer des fichiers en utilisant des wildcards:
    { 
       "compilerOptions": {}, 
       "include": [ 
           "src/**/*"
       ] 
    } 
    

    Les wildcards utilisables sont:

    • "*" pour indiquer un ensemble de caractères.
    • "?" pour indiquer un seul caractère.
    • "**/" pour parcourir récursivement tous les répertoires enfants.

    Dans l’exemple, le compilateur va donc parcourir récursivement tous les fichiers et répertoires se trouvant dans le répertoire src.

  • "exclude": permet d’exclure des fichiers se trouvant dans le parcours des fichiers .ts/.tsx/.d.ts du compilateur:
    { 
       "compilerOptions": {}, 
       "exclude": [ 
           "folder", 
           "file*.ts, 
           "**/innerFile*.ts"
       ] 
    } 
    

    Les wildcards indiquées plus haut sont aussi utilisables avec le paramètre "exclude".

Import relatif et non-relatif

Il faut distinguer 2 types d’import:

  • Les imports relatifs: ces imports se font de façon relatives au fichier dans lequel on veut effectuer l’import. Ainsi si on écrit dans le fichier fileA.ts:
    import * from './fileB.ts';
    

    La recherche de la dépendance se fera dans le même répertoire que le fichier fileA.ts

  • Les imports non-relatifs: ces imports ne se font pas en fonction de l’emplacement du fichier dans lequel on effectue l’import. Il se fait dans des répertoires définis suivant une stratégie de recherche. Cette stratégie dépend de la configuration.

    Pour ce type d’import, on indique directement le nom du module (sans indication de répertoire ou d’extension de fichier).

    Par exemple:

    import * from 'moduleName'; 
    

Stratégies de résolution des modules

Il existe 2 stratégies de résolution des modules:

  • "Classic": correspond à un parcours du répertoire de compilation et des répertoires parents.
  • "Node": cette stratégie imite la stratégie de résolution de Node.js avec les fichiers Javascript. Elle permet de parcourir le répertoire où se trouve le fichier .ts d’où se fait l’import ou le répertoire "node_modules".

On peut préciser la stratégie de résolution en ajoutant l’option --moduleResolution au lancement du compilateur:

tsc --moduleResolution "Node" 

Par défaut, la stratégie est classique si le type de module choisi dans tsconfig.json est "AMD", "System" ou "ES6". Si un autre type de module est choisi, la stratégie est "Node".

Stratégie classique: “Classic”

La stratégie classique consiste à parcourir le répertoire de compilation du fichier d’où se fait l’import en cherchant les fichiers avec une extension .ts ou .d.ts:

  • Dans le cas d’un import relatif: la recherche se fait seulement dans le répertoire cible de l’import. Par exemple si le fichier dans /folder/file1.ts effectue un import:
    import { module2 } from '../folder2/moduleName'; 
    

    Alors le parcours se fera en cherchant successivement les fichiers /folder2/moduleName.ts et /folder2/moduleName.d.ts.

  • Dans le cas d’un import non-relatif: la recherche se fait dans le répertoire du fichier d’où se fait l’import et dans les répertoires parents.

    Par exemple si le fichier dans /folder/file1.ts effectue un import:

    import { module2 } from 'moduleName'; 
    

    Alors le parcours se fera en cherchant successivement les fichiers: /folder/moduleName.ts, /folder/moduleName.d.ts, /folder/moduleName.ts et /folder/moduleName.d.ts.

Stratégie “Node”

Cette stratégie imite celle de Node.js pour la résolution des imports des fichiers Javascript:

  • Dans le cas d’un import relatif: le parcours se fait dans le répertoire du fichier d’où se fait l’import. Par exemple si le fichier dans /folder/file1.ts effectue un import:
    import { module2 } from '../folder2/moduleName'; 
    

    Alors le parcours se fera successivement en cherchant les fichiers:

    • /folder2/moduleName.ts, /folder2/moduleName.tsx ou /folder2/moduleName.d.ts
    • /folder2/moduleName/package.json (en utilisant la propriété "types" dans ce fichier)
    • /folder2/moduleName/index.ts, /folder2/moduleName/index.tsx ou /folder2/moduleName/index.d.ts.
  • Dans le cas d’un import non-relatif: le parcours se fait dans le répertoire node_modules (ce répertoire correspond au répertoire d’installation des packages NPM).

    Par exemple si le fichier dans /folder/file1.ts effectue un import:

    import { module2 } from 'moduleName'; 
    

    Alors le parcours se fera dans le répertoire node_modules se trouvant dans le répertoire du fichier en cherchant un fichier correspondant aux chemins suivants:

    • /folder/node_modules/moduleName.ts, /folder/node_modules/moduleName.tsx ou /folder/node_modules/moduleName.d.ts,
    • /folder/node_modules/moduleName/package.json,
    • /folder/node_modules/moduleName.index.ts, /folder/node_modules/moduleName.index.tsx ou /folder/node_modules/moduleName.index.d.ts.

    Le parcours se fera, ensuite, dans le répertoire node_modules se trouvant dans un répertoire parent:

    • /node_modules/moduleName.ts, /node_modules/moduleName.tsx ou /node_modules/moduleName.d.ts,
    • /node_modules/moduleName/package.json,
    • /node_modules/moduleName.index.ts, /node_modules/moduleName.index.tsx ou /node_modules/moduleName.index.d.ts.

Paramètres de compilation “baseUrl”, “paths” et “rootDirs”

Ces paramètres peuvent modifier les chemins utilisés lors de l’application de la stratégie de recherche des modules à importer.

“baseUrl”
Il permet d’indiquer un préfixe que le compilateur appliquera systématiquement aux imports non-relatifs. Ce paramètre peut être précisé à l’exécution du compilateur:

tsc --baseUrl "../moduleFolder/" 

Il peut aussi être précisé dans le fichier tsconfig.json:

{ 
   "compilerOptions": { 
       "baseUrl": "../moduleFolder/"
   } 
} 

“paths”
Ce paramètre permet de préciser un mapping entre des noms de module et leur chemin effectif. La valeur du paramètre "baseUrl" est rajouté aux chemins des mappings précisés dans "paths".

Par exemple pour la configuration suivante:

{ 
   "compilerOptions": { 
       "baseUrl": "../moduleFolder/", 
       "paths": { 
            "moduleName": [ "otherModules/moduleName" ] 
       }
   } 
} 

Si on effectue l’import non-relatif suivant:

import { module2 } from 'moduleName'; 

L’import sera fait dans le répertoire (par rapport à l’emplacement du fichier tsconfig.json):

../moduleFolder/otherModules/moduleName 

Il est possible d’utiliser plusieurs répertoires de mapping:

{ 
  "compilerOptions": { 
    "baseUrl": "../moduleFolder/", 
    "paths": { 
      "moduleName": [  
        "otherModuleFolder1/moduleName", 
        "otherModuleFolder2/moduleName"
      ] 
    } 
  } 
} 

L’import sera fait en cherchant dans les répertoires: ../moduleFolder/otherModuleFolder1/moduleName et ../moduleFolder/otherModuleFolder2/moduleName.

Il est aussi possible d’utiliser le caractère "*" pour introduire plus de flexiblité au moment de l’import. Par exemple si le fichier de configuration est:

{ 
  "compilerOptions": { 
    "baseUrl": "../moduleFolder/", 
    "paths": { 
      "*": [  
        "otherModuleFolder1/*", 
        "otherModuleFolder2/*"
      ] 
    } 
  } 
} 

Si on effectue l’import:

import 'folder/moduleName'; 

L’import sera fait en cherchant dans les répertoires: ../moduleFolder/otherModuleFolder1/folder/moduleName et ../moduleFolder/otherModuleFolder2/folder/moduleName.

“rootDirs”
Ce paramètre permet d’indiquer au compilateur des répertoires virtuels qui doivent être mergés après le déploiement de l’application.

Par exemple, s’il existe plusieurs répertoires indiqués dans le paramètre "rootDirs": "folder1", "folder2" et "folder3". A chaque fois qu’un import relatif est effectué à partir de l’un de ces répertoires, le compilateur va effectuer une recherche des modules dans tous les répertoires comme si leur contenu était mergé.

Si la configuration est:

{ 
  "compilerOptions": { 
    "rootDirs": [  
      "otherModuleFolder1", 
      "otherModuleFolder2"
    ] 
  } 
} 

Et si un import est effectué de cette façon:

import 'otherModuleFolder1/moduleName'; 

Le compilateur va effectuer la recherche dans les répertoires otherModuleFolder1/moduleName et otherModuleFolder2/moduleName.

Débugger la résolution de modules

En rajoutant l’option suivante au compilateur, on peut avoir des messages de logs lors de la résolution des modules:

tsc --traceResolution 

Pour conclure…

Comme en Javascript, les modules en Typescript peuvent être implémentés de façon très différente. Il est possible de passer par les namespaces qui correspondent à une séparation logique du code et qui sont spécifiques à Typescript. On peut aussi utiliser des syntaxes plus proches du Javascript avec des formats comme CommonJS, AMD ou ES2015. L’intérêt d’utiliser une syntaxe plus proche de celle en Javascript permet de coller davantage à la notion des modules Javascript séparant le code en fichiers.
D’une façon générale, il est conseillé de privilégier la notion des modules Javascript par rapport aux namespaces. En effet, les namespaces ajoutent une couche hiérarchique aux objets dans le but d’éviter les collisions de nom. A l’opposé les modules Javascript, du fait de leur séparation en fichiers, évitent les collisions de nom sans ajouter de couche hiérarchique.
Comme on a pu le voir, la définition de modules en Typescript hérite de la syntaxe Javascript et ajoute la notion de namespace. En revanche la notion d’import de modules externes n’a pas été abordé dans cet article. Ce point fera l’objet d’un article ultérieur.

Références

Leave a Reply