Introduction à TypeScript

À la découverte de TypeScript, des appels asynchrones, des callbacks, des promesses, des mots clés await, async et du test à l’aide de Jest.

1. Crédits

La version initiale de cet exercice a été réalisée par Olivier Barais. La version originelle est disponible ici.

2. Étape 1 : TypeScript: JavaScript avec des types

TypeScript a explosé en popularité en 2019 et continue sa folle course. C’est le premier langage de programmation à être présent dans le top 10 des langages les plus utilisés en moins de 5 ans d’existence.

2.1. La genèse

Nous sommes en 2010. Tout le monde se plaint de JavaScript, mais JavaScript est partout. Pour votre culture sociologique du monde des développeurs, nous vous joignons la vidéo ci-dessous.

En 2010, chez les clients externes de Microsoft, tout le monde est d’accord sur un point. Le Javascript n’est pas fait pour les projets à grandes échelles. Mais Javascript est quand même utilisé dans ces projets de grande envergure. Ces projets sont en Javascript pour une raison simple : les navigateurs n’acceptent que le Javascript ! Tout le monde est bloqué avec.

YJYNV2b

C’est avec ce problème en tête que Microsoft va commencer à travailler sur TypeScript. Le projet va être développé pendant deux ans en interne. En octobre 2012, la version 0.8 de TypeScript passe publique pour la première fois. Explosion en 2015 aussi quand Google invite le responsable du projet TypeScript de chez Microsoft à sa conférence (ng-conf), conférence organisée par Google à destination des développeurs Web. Quand deux grands acteurs en forte concurrence converge sur un choix technique, l’impact sur le domaine IT est forcément important.

Extrait de ce moment d’histoire pour notre petite communauté IT (pour regarder un soir tard, pas pendant le TP)

2.2. Rappel: c’est quoi TypeScript ?

TypeScript est un langage de programmation open source fait par Microsoft. Pour être plus précis, c’est un sur-ensemble de Javascript. C’est-à-dire que tout programme Javascript existant est déjà un programme TypeScript valide.

TypeScript est un langage multi paradigmes. Un développeur peut faire du fonctionnel, comme de l’orienté objet sans problème. Et nous parlons de vrais objets, pas d’orienté objet via prototype comme en Javascript. Le fait que TypeScript soit un langage à objet et fortement typé a tout changé. Le système de type particulièrement avancé et flexible a renforcé l’attrait pour ce langage.

TypeScript ne remplace pas JavaScript. Le modèle de développement est simple, le développeur développe en TypeScript dans des fichiers .ts. Le compilateur TypeScript (tsc) traduira une application écrite en TypeScript en une application Javascript. Développer en TypeScript permet de sécuriser une partie du développement. En particulier, l’utilisation des types permet d’éviter quantité de bugs lors de la phase de compilation. Après compilation, on est en Javascript. Ce code Javascript est interprété par le navigateur et est exposé au même risques de sécurité que du code directement écrit en Javascript. Mais comme le développeur est passé par TypeScript au préalable, la sûreté de l’application est « renforcée ». Et c’est ça qui fait le succès de TypeScript.

Passons à la pratique.

3. Étape 2 : Initialisation de votre premier projet TypeScript

  1. Dans l’interface de commande, tapez les commandes suivantes :

# Création d'un nouveau projet
mkdir tp-TypeScript
cd tp-TypeScript
npm init

La commande posera différentes questions. Vous pouvez laisser les valeurs par défaut, sauf pour les auteurs et le nom du projet.

Cela crée le fichier package.json qui en résumé conserve une description du projet, ses dépendances, ses scripts de build, etc.

La commande npm init est utilisée pour générer un fichier package.json dans le répertoire du projet.

Ce fichier contient un ensemble d’information concernant le projet, comme son nom, les dépendances utilisées, les scripts utilisés pour le construire/produire, etc.

  1. Exécutez la commande suivante pour configurer TypeScript dans le projet :

# Initialisons TypeScript pour ce projet
tsc --init

Cette commande crée le fichier tsconfig.json qui en résumé conserve les options du compilateur TypeScript.

La commande tsc -init est utilisée pour générer un fichier tsconfig.json dans votre répertoire de projet, qui contient des options de compilation et des paramètres pour votre projet TypeScript.

En personnalisant les options du fichier tsconfig.json, vous pouvez adapter le processus de compilation TypeScript à votre projet.

3.1. Création d’un répertoire source

La création d’un répertoire src distinct pour vos fichiers TypeScript est une pratique courante dans le développement web moderne.

En Voici quelques raisons `:

  • Organisation : En créant un répertoire src distinct, vous pouvez séparer vos fichiers TypeScript des autres fichiers du projet, ce qui facilite la navigation et l’organisation de votre projet.

  • Clarté : Le répertoire src indique clairement où se trouvent les fichiers source de votre projet TypeScript, ce qui facilite la compréhension et la contribution des autres développeurs à votre projet.

  • Séparation des préoccupations : Séparer vos fichiers sources des autres fichiers du projet permet de s’assurer que votre projet est bien organisé et qu’il respecte les meilleures pratiques en matière de structure de code et de séparation des préoccupations.

Pour créer un répertoire src dans votre projet TypeScript, vous pouvez suivez les étapes suivantes :

  1. Ouvrez votre terminal ou votre invite de commande.

  2. Naviguez jusqu’au répertoire racine de votre projet TypeScript.

  3. Tapez la commande suivante pour créer un nouveau répertoire src dans votre projet :

mkdir src
  • Cela créera un nouveau répertoire src dans votre répertoire de projet.

  • Vous pouvez désormais placer vos fichiers TypeScript dans le répertoire src. Il est recommandé de créer des sous-répertoires dans le répertoire src pour mieux organiser vos fichiers, si votre projet est important.

En résumé, la création d’un répertoire src distinct pour vos fichiers TypeScript peut contribuer à l’organisation et à la clarté de votre projet, ainsi qu’au respect des meilleures pratiques en matière de séparation et de structure du code.

Pour créer un répertoire src, il suffit d’utiliser la commande mkdir dans votre terminal ou à l’invite de commandes.

3.2. Ajouter et compiler des fichiers TypeScript dans le répertoire src

Les fichiers TypeScript sont similaires aux fichiers JavaScript ordinaires en ce sens qu’ils contiennent du code écrit dans le langage TypeScript, qui est un sur-ensemble de JavaScript.

Cependant, les fichiers TypeScript sont différents des fichiers JavaScript classiques, car ils sont conçus pour être d’abord traduits en code JavaScript qui peut être exécuté dans un navigateur web ou sur un serveur.

Pour ajouter des fichiers TypeScript au répertoire src de votre projet, procédez comme suit :

  1. Ouvrez votre terminal ou votre invite de commande.

  2. Naviguez jusqu’au répertoire src de votre projet TypeScript.

  3. Créez un nouveau fichier TypeScript en tapant la commande suivante :

touch app.ts

Cela créera un nouveau fichier TypeScript nommé app.ts dans le répertoire src.

Vous pouvez maintenant ajouter du code TypeScript au fichier app.ts. Le code TypeScript peut inclure des fonctionnalités qui n’étaient pas prises en charge par JavaScript avant la norme ES6 (ES2015+), telles que les interfaces et les classes, ainsi que des fonctionnalités qui ne sont pas présentes dans la norme actuelle de JavaScript, ES2023, comme les types natifs, les types génériques, les annotations et les énumérations.

Maintenant, nous allons préciser au compilateur TypeScript que les fichiers sources se trouvent dans le répertoire ./src/.

Nous allons aussi lui demander de placer les résultats de compilation dans le répertoire ./dist/ pour ne pas mélanger le code source (fichiers .ts) et le code compilé (fichiers .js).

Pour ce faire, ajouter les ligne suivante dans le fichier tsconfig.json (dans la sous-partie "compilerOptions")

"rootDir": "./src/",
"outDir": "./dist/",

On en profitera pour mettre l’option inlineSourceMap à true. C’est très pratique en mode debug car cela permet de déboguer directement le code TypeScript, au lieu de déboguer le code JavaScript résultant de la traduction.

Cette option indique au compilateur TypeScript d’inclure les éléments de traçabilité entre le code js généré par le compilateur et le fichier .ts d’origine (c’est ce que l’on appelle les sourcemap`).

Dans le fichier tsconfig.json, toujours dans la sous-partie compilerOptions, ajoutez la ligne suivante.

"inlineSourceMap" : true,

Pour compiler des fichiers TypeScript en JavaScript, vous pouvez utiliser la commande tsc, qui est l’interface de ligne de commande du compilateur TypeScript.

Si vous installé TypeScript localement, grâce à nom, avec npm --save-dev install typescript, vous pouvez exécuter ce compilateur avec npx : npx tsc.

Voici les étapes à suivre :

  1. Ouvrez votre terminal ou votre invite de commande.

  2. Naviguez jusqu’au répertoire racine de votre projet TypeScript.

  3. Exécutez la commande suivante pour compiler vos fichiers TypeScript  :

tsc

Cette commande traduira tous les fichiers TypeScript de votre projet et générera les fichiers JavaScript correspondants dans le répertoire de sortie spécifié dans le fichier tsconfig.json.

La traduction des fichiers TypeScript en JavaScript est nécessaire, car les navigateurs web et les interpréteurs côté serveur comme Node.js ne peuvent exécuter que du code JavaScript.

En compilant les fichiers TypeScript en JavaScript, vous pouvez rendre votre code TypeScript compatible avec les navigateurs web et les serveurs et lui permettre d’être exécuté sur ces plateformes.

En résumé, la traduction des fichiers TypeScript en JavaScript est nécessaire pour rendre votre code compatible avec les navigateurs et les interpréteurs JavaScript s’exécutant coté serveur, comme Node.js, et peut être réalisée à l’aide de la commande tsc.

3.3. Ajout d’un comportement simple dans le fichier TypeScript.

Nous allons maintenant ajouter un comportement simple dans le fichier TypeScript. Ce comportement va afficher du log (une trace) dans la console.

Dans votre fichier app.ts, ajoutez la ligne suivante et enregistrez les modifications :

console.error("Un jour j'écrirai un code sans bug");

à la racine de votre projet, compilez et exécutez votre code grâce aux commandes suivantes :

# Recompilez votre fichier TS
tsc

# Exécutez votre application
node dist/app.js

Cette étape de compilation est à effectuer à chaque fois que vous souhaitez exécuter une nouvelle version de votre programme. Par la suite, nous verrons comment automatiser la compilation.

L’initialisation du projet est terminée. Cela vous permet de travailler proprement à partir de là, nous rentrons dans le vif du sujet.

Ces étapes d’initialisation peuvent être reprises pour d’autres projets personnels utilisant du TypeScript.

Nous avons initialisé un projet TypeScript et utilisé Node.js pour exécuter ce code sur notre machine.

4. Étape 3 : Vos premières lignes de code en TypeScript

Nous allons maintenant créer un programme écrit en TypeScript pour scanner notre système de fichiers afin de détecter des fichiers et des répertoires avec des permissions étranges.

Nous appellerons permissions étranges par exemple le fait qu’un sous-répertoire ait davantage de droits qu’un de ses parents ou un fichier avec une extension connue comme un document (.txt, .csv, .docx) avec des permissions d’exécutions par exemple.

Pour cela commençons par créer les structures de classes[1] de notre projet.

Ces classes peuvent être définies dans le fichier app.ts préalablement créé.
  1. Créez :

    1. Une classe MyFile en TypeScript qui possède un nom, une extension, une taille, des permissions

    2. Une classe Folder qui contient une liste de FSElement, un nom et une taille

    3. Une interface FSElement qui contient un nom et une taille.

Les classes MyFile et Folder doivent implémenter l’interface FSElement.

  1. Créez un ou deux objets MyFile et Folder et afficher les caractéristiques de ces objets dans la console en utilisant console.log(obj);

  2. Compilez et exécutez votre code pour vérifier que tout fonctionne correctement.

  3. Vous pourrez utiliser l’énumération suivante pour modéliser la notion de permission :

enum Permission {
    Read = 'r',
    Write = 'w',
    Exec = 'x',
    Sticky = 's',
  }
  • Pour la classe Folder, nous souhaitons que l’attribut taille soit juste un getter.

  • Vous trouverez plus d’informations sur les Getters et Setters TypeScript sur la documentation officielle.

  1. Modifiez la définition de l’interface FSElement pour définir l’attribut taille comme readonly`et la classe `Folder pour que cette dernière utilise la notion de Getter pour l’attribut taille. Le code du Getter ne fera que faire la somme de toute la taille de ces fils.

    1. Vous troverez plus de renseignements sur les attributs en lecture seule sur la documentation officielle.

  2. Créez une ou deux instances de MyFile et de Folder et affichez les caractéristiques de ces objets dans la console en utilisant console.log(obj);

  3. Compilez et exécutez votre code pour vérifier que tout fonctionne correctement.

  4. Entre autre, vérifiez la mise en œuvre de votre Getter sur la taille d’un répertoire.

5. Étape 4 : Comprendre la programmation asynchrone

L’exécution de fonctions en JavaScript est, en général, synchrone. Cependant, certaines fonctions proposées par les environnements hôtes (le navigateur, Node.js, etc.) sont soit potentiellement lentes (par ex. le téléchargement de ressources), soit dépendent de l’interaction avec l’utilisateur (attente d’un clic de souris, etc.).

Afin de ne pas bloquer l’exécution du programme (ou le chargement du reste de la page web), celles-ci ont un fonctionnement asynchrone, c’est à dire, non bloquant.

Dans cette étape, nous allons présenter les différents modèles de programmation proposés pour la gestion des appels à des fonctions asynchrones, tels que:

  • Les fonctions de rappel (callback),

  • Les promesses,

  • L’utilisation des mots-clefs async et await.

5.1. Pourquoi des appels asynchrones ?

Le moteur d’exécution de JavaScript est mono-thread; une seule instruction peut être exécutée à la fois. Lorsqu’une fonction, considérée comme asynchrone, est appelée, celle-ci est placée sur une pile de fonctions en attente d’exécution, pour ne pas bloquer l’exécution de la fonction courante.

Lorsque la fonction courante a terminé son exécution, une des fonctions en attente est retirée de la pile et est exécutée à son tour, c’est la boucle événementielle, ou Event Loop.

De base en JavaScript, les fonctions sont synchrones et les accès I/O sont asynchrones (accès fichiers, requêtes HTTP, etc.). La communauté a essayé de faire converger le format des méthodes dites de rappel, ou callback. Les méthodes qui sont appelées quand l’exécution de la fonction JavaScript a eu lieu.

Pour bien comprendre la différence entre fonction synchrone et asynchrone, nous vous conseillons la lecture de l’introduction faite par la Fondation Mozilla.

5.2. Les appels asynchrones : quelles conséquences ?

La programmation de fonctions utilisant des fonctions asynchrones doit tenir compte du fait que les résultats des appels à ces dernières peuvent être obtenus dans un ordre et après un délai aléatoires.

C’est souvent très perturbant, car le cerveau humain a tendance a naturellement penser synchrone et execution ligne à ligne. En particulier, si une ligne de code est juste en dessous d’une autre alors on s’attend à ce qu’elle soit exécutée après. Ce n’est pas forcément vrai en programmation asynchrone.

5.3. Une première erreur classique quand on débute en asynchrone

Nous allons dans la suite de ce TP utiliser le module filesystem de Node.js, qui permet de faire des actions sur votre système de fichiers. Nous allons donc tout d’abord indiquer à TypeScript que nous allons utiliser la librairie standard de Node.js.

  1. Dans votre terminal, exécutez la commande suivante :

# Indique à TypeScript que nous allons utiliser la librairie standard de nodeJs.
npm install -D @types/node@22.5.5
  1. Puis, dans le fichier app.ts, ajoutez le code suivant :

import * as fs from "fs";
let content = undefined;
  fs.readFile('./dist/app.js', (err,data) =>  {
    content = data;
  });
console.log(content);

Ici, la fonction fléchée (err,data) ⇒ {content = data;}); est la fonction de rappel.

C’est une fonction anonyme, dont les deux paramètre sont err et data. Cette fonction de rappel est passée en paramètre à la fonction readFile().

Que la fonction readFile() s’arrête sur une erreur ou sur un résultat, Javascript exécutera la fonction de callback. Lors de l’appel de cette fonction, Javascript donnera au paramètre err la valeur de l’erreur et au paramètre data la valeur du résultat.

  1. Compilez et exécutez le programme précédent.

  2. Vous verrez que la variable content reste nulle.

En effet, la fonction de rappel, ici la portion de code (err,data) ⇒ {…​}, dont l’effet est d’affecter data à content est exécutée après l’instruction console.log(content).

Effectivement, ce style de programmation ne nous est pas naturel. Pire encore, deux fonctions de rappel, l’une en dessous de l’autre, ne s’exécuteront pas forcément de haut en bas.

Prenons l’exemple suivant :

  fs.readFile('./dist/app.js', (err,data) =>  {
    console.log('readFile');
  });

  fs.readdir('/tmp', (err,data) =>  {
    console.error('/tmp');
  });
  fs.readdir('/usr/lib', (err,data) =>  {
    console.error('/usr/lib');
  });
  1. Ajoutez le code précédent au fichier app.ts.

  2. Compilez et regardez l’ordre d’exécution des fonctions de rappel.

Vous verrez qu’il n’est pas celui que l’on attendait en lisant le code de manière linéaire. Le code n’est même pas déterministe, si l’on regarde la spécification du runtime JavaScript.

Donc même si vous exécutez ce code en ayant l’impression que l’exécution est déterministe, elle pourrait être différente sur un autre interprète Javascript ou une autre machine.

5.4. Gestion des erreurs

En Node.js, tous les appels asynchrones prennent une seule méthode de rappel. Cette méthode prend deux paramètres d’entrée. Le premier paramètre est un objet représentant l’exception. Le second est un objet représentant le résultat. Si l’objet exception est vide alors l’exécution de l’appel asynchrone s’est passé sans difficulté.

Voici un exemple de fonction de rappel dans ce style de programmation :

const file = "file.txt"; (1)

fs.readFile(file, (2)
    (err, data) => {
        if (err) {
            return console.log("Error: " + err);
        }
        console.log("Function successfully returned: ", data);
    }
)
1 Fichier inexistant
2 L’exécution doit lever une erreur

5.5. L’enfer des rappels

Les fonctions de rappel peuvent devenir infernales à gérer lorsque l’appel à une fonction asynchrone dépend du résultat d’une autre fonction asynchrone.

Supposons que nous devons vérifier les permissions des fichiers d’un répertoire et ajouter systématiquement le droit en lecture à l’utilisateur courant si ce dernier ne l’a pas.

Cela donnerait un code comme celui-là, car on combine trois appels de fonctions asynchrones suivantes : readdir(), stat() et chmod().

fs.readdir(folder, (err, data) => {
  if (err) {
    console.error("cannot read this folder");
    return;
  }
  data.forEach((file) => {
    fs.stat(folder + "/" + file, (error, stats) => {
      if (error) {
        console.error("cannot get permission for this file", file, error);
        return;
      } else {
        if (!(4 & parseInt((stats.mode & parseInt("777", 8)).toString(8)[0]))) {
          console.error("change permission for ", file);
          fs.chmod(
            folder + "/" + file,
            "" + ((stats.mode & parseInt("777", 8)) + 400),
            (error1) => {
              if (error1) {
                console.error("cannot change permission for this file", file);
                return;
              }
            }
          );
        }
      }
    });
  });
});

L’imbrication des appels asynchrones en passant les fonctions de rappel en cas de réussite et en cas d’erreur, sans parler des cas où il faut s’assurer que tous les appels à des fonctions asynchrones exécutées en parallèle soient terminés, devient vite infernal ! On parle de "Callback Hell".

Dans ces situations, il vaut mieux utiliser un autre mécanisme : les promesses.

6. Étape 5 : Les promesses

Une promesse est un objet Promise, standardisé depuis ES6 et qui permet une simplification de l’écriture de fonctions asynchrones.

Un objet Promise est instancié en lui fournissant, en argument la fonction asynchrone à exécuter qui prend elle-même en arguments, la fonction de rappel à exécuter en cas de succès (resolve) et celle à exécuter en cas d’erreur (reject). Ainsi, on peut créer une promesse avec un code de la forme :

const myPromise = new Promise((resolve, reject) => {
   ... traitement lent
   ... construisant une valeur résultat x
  resolve(x); (1)
  ...
  ...
  reject(...); (2)
});
1 Déclenche la résolution de la promesse avec comme paramètre: x
2 Traitement de l’échec de la promesse

Voici un exemple simple de promesse dont le rôle est de calculer la factorielle de 1000 (c’est notre traitement lent). Ce calcul peut donner un résultat ou échouer dans le cas d’un débordement.

const myPromise = new Promise((resolve, reject) => {
        //
        let x = 1
        for (let i = 2; i < 1000; i++) { (1)
            x = x * i
        }
        if (x === Infinity) {
            reject(new Error("Overflow")); (2)
        } else {
            resolve(x); (3)
        }
    })

myPromise
    .then(result => console.log("Succes: ", result))
    .catch(err => console.log("Error: ", err))
1 Traitement lent. On calcule la factorielle 100 et on place le résultat dans x.
2 On applique la fonction de rappel gérant les erreurs en lui passant la raison de l’erreur
3 On déclenche la résolution de la promesse avec comme paramètre: x
  1. Si l’on revisite le code précédent, utilisant les promesses pour vérifier la présence d’un fichier file.txt, nous obtiendrons ceci le code suivant :

import * as fs from "fs/promises"; (1)
/* au lieu de import * as fs from "fs";*/

fs.readFile("./file.txt")
    .then(data => console.log("Function successfully returned: ", data))
    .catch(err => console.log("Error: ",err))
1 Il faut changer l’importation pour utiliser le module fs/promises qui utilise les promesses.

6.1. Combiner plusieurs promesses

L’avantage est alors de pouvoir faire aussi simplement des points de synchronisation. Imaginons que je lance trois appels asynchrones et que je souhaite continuer quand ces trois appels sont terminés.

const promises = [];
promises.push(fs.readFile("./dist/app.js"));
promises.push(fs.readdir("/usr/lib"));
promises.push(fs.readdir("/tmp"));
Promise.all(promises).then( ... )

La méthode Promise.all() renvoie une promesse qui est résolue lorsque l’ensemble des promesses contenues dans l’itérable passé en argument ont été résolues ou qui échoue avec la raison de la première promesse qui échoue au sein de l’itérable. (voir Promise.all())

Il existe d’autres méthodes pour obtenir un pattern de coordination quand on doit combiner les comportements de plusieurs promesses (Promise.any(), Promise.race(), Promise.allSettled()). N’hésitez pas à aller voir la documentation pour comprendre leurs usages.

6.2. Écrire des fonctions asynchrones comme des fonctions synchrones: async/await

ES7 a introduit un support au niveau langage de la notion de promesse afin d’éviter l’utilisation de la fonction then(). Il a introduit deux nouveaux mots-clefs permettant de simplifier davantage l’écriture de fonctions asynchrones pour qu’elles ressemblent un peu aux fonctions synchrones.

Dans ce modèle, la déclaration d’une fonction asynchrone doit être précédée du mot clé async et son résultat sera nécessairement une promesse. Prenons, par exemple, la déclaration de fonction suivante :

async function f(x: number): Promise<number> {
    for (let i = 0; i < 1000000000; i++) { }
    return x + 1
}

Avec cette fonction, si on réalise un appel de la forme const v= f(1) on obtiendra (immédiatement) un objet de type Promise<number>, c’est-à-dire, la promesse d’un résultat de type number à venir.

Cette promesse sera associée à v. Cependant, il est possible d’obtenir le résultat (et non juste une promesse) en ajoutant devant l’appel de fonction le mot clé await. En clair, la signification de await ici est d'attendre la résolution de la promesse.

Ainsi, si on écrit const v = await f(1), l’exécution sera bloquée jusqu’à la résolution de la promesse et la production du résultat. On a retrouvé la synchronisation.

Petite restriction, await ne peut être utilisé que dans des fonctions, elles-mêmes asynchrones. Ainsi, pour tester notre exemple, on ne peut pas écrire directement :

const v = await f(1);
console.log(v);

Mais, à la place, on doit empaqueter notre appel à f() dans une fonction asynchrone :

async function test() {
    const v = await f(1);
    console.log(v);
}
test();

Les deux instructions de la fonction test seront bien exécutées dans l’ordre. À noter que le code équivalent écrit avec des promesses et des then() serait :

function test() {
    f(1).then(data => console.log(data));
}
test();

L’utilisation des await/async simplifie grandement l’écriture des fonctions utilisant des fonctions asynchrones. Voici le code qui permet de récupérer l’objet représentant le descripteur du fichier ./dist/app.js.

async function myFileReaderFunction(){
    const data = await fs.readFile("./dist/app.js");
}

Le code équivalent à l’exemple sur l’enfer des rappels est donné ci-dessous. Nous espérons vous convaincre qu’il est plus facile à relire  :

async function checkFolder(folder:string){
  const data = await fs.readdir(folder);
  for (const file of data){
    const stats = await fs.stat(folder + "/" + file)
    // (stats.mode & parseInt("777", 8) est un moyen de passer les droits sur le système de fichiers en base 8.
    // On récupère ensuite la valeur entre 0 et 7 du premier chiffre. C'est les droits pour l'utilisateurs courant. Petite explication ici
    // https://astuces-informatique.com/que-signifie-chmod-777/
    if (!(4 & parseInt((stats.mode & parseInt("777", 8)).toString(8)[0]))) {
      console.error("change permission for ", file);
      await fs.chmod( folder + "/" + file,
            "" + ((stats.mode & parseInt("777", 8)) + 400));
     }
  }
}

checkFolder("/tmp");

6.3. Gestion d’erreur

La gestion d’erreur doit être mise en place en tenant compte qu’une fonction déclarée asynchrone par async retourne une promesse. Nous utiliserons alors un try catch classique :

import * as fs from "fs/promises";

const file = "file.txt";

async function readMyFile(){
    try{
        const data = await fs.readFile(file);
    } catch (e){
        console.error(e);
    }
}

Dans la suite du TP, on vous incite fortement à utiliser la notation à l’aide des await et async. Cette notation permet également d’utiliser les promesses avec .then() si besoin.

7. Étape 6 : Petit projet

À partir de la documentation de l’API du FS, fabriquez une méthode appelée printFileWithNullSize()), attachée à la classe Folder qui, à partir de la structure de données préalablement créée (MyFile, Folder, FSElement), explore ce graphe pour détecter les fichiers donc la taille est zéro.

On rappelle que la taille d’un répertoire est la somme des tailles de ses éléments. Il faudra préalablement créer la fonction qui crée le graphe d’objet instance de Folder et MyFile, à partir du chemin d’un répertoire donné en paramètre.

Pour lister les fichiers d’un répertoire, vous pouvez utiliser la fonction suivante :

fs.readdir(path)

Pour savoir si un élément contenu dans un répertoire est un répertoire ou un fichier, la fonction fs.stat(), vue précédemment, retourne un objet sur lequel on a accès à l’API suivante :

stats.isDirectory()

De même, pour connaître la taille d’un fichier à l’aide de la bibliothèque fs, on utilisera l’attribut size, de l’objet retourné par fs.stat().

stats.size
Enfin, n’oubliez pas vos cours sur la récursivité afin de créer un graphe complet.
Automatisation de la phase de compilation.
  • Recompiler à chaque fois que l’on souhaite tester l’exécution, c’est fastidieux.

  • Il est classique d’utiliser des mécanismes qui vont surveiller l’état du système de fichiers et déclencher la compilation pour vous.

  • Pour ce faire, à la racine du projet, lancer la commande :

tsc --watch

Cela lancera un processus qui surveille tous vos fichiers TypeScript et relance la compilation quand un fichier .ts est modifié.

  1. Étendez votre structure de données pour ajouter la notion de permission et

  2. Vérifiez de manière récursive qu’aucun enfant n’a des permissions moins restrictives qu’un de ses parents.

De nouveau, on modifiera la fonction qui permet de créer le graphe d’objets Folder et MyFile, à partir d’un chemin donné sous forme de chaine de caractères et on ajoutera une méthode à la classe Folder pour la partie vérification de droits.

8. Étape 7 : Comprendre certains aspects du système de type

Par défaut, en TypeScript nous avons pas mal de flexibilité sur la configuration du typechecker (configuration au niveau du fichier tsconfig.json).

En effet, différents utilisateurs viennent à TypeScript en recherchant différentes choses dans un vérificateur de type. Certains recherchent une expérience plus souple qui peut aider à valider seulement certaines parties de leur programme, tout en disposant d’un outil décent. C’est l’expérience par défaut avec TypeScript (pas celle configurée dans notre projet), où les types sont optionnels, l’inférence prend les types les plus indulgents et il n’y a pas de vérification pour les valeurs potentiellement nulles ou indéfinies. Ces valeurs par défaut sont mises en place pour ne pas vous gêner. Si vous migrez du JavaScript existant, cela peut être une première étape souhaitable.

En revanche, beaucoup d’utilisateurs préfèrent que TypeScript valide le plus possible dès le départ et c’est pourquoi le langage fournit également des paramètres de demande de rigueur forte, c’est notre cas dans ce projet. Utiliser une demande de rigueur plus forte peut nécessiter un peu de travail supplémentaire, mais en général cela se paie sur le long terme et permet des vérifications plus approfondies et des outils plus précis. Lorsque cela est possible, une nouvelle base de code devrait toujours activer ces vérifications.

Prenons l’exemple de deux d’entre elles : (i) l’interdiction de l’inférence à Any et (ii) le contrôle strict de la nullité.

8.1. Interdiction de la référence à Any

Rappelons qu’à certains endroits, TypeScript n’essaie pas de déduire les types pour nous et se rabat sur le type le plus indulgent : Any. Ce n’est pas la pire chose qui puisse arriver — après tout, se rabattre sur any est juste l’expérience JavaScript ordinaire de toute façon. Any est le void de C++, cela désactive toute vérification de type.

Un objet de type Any peut être affecté à n’importe quelle variable quelque soit son type, on peut lui appeler n’importe quelle méthode, etc.

Cependant, l’utilisation de Any va souvent à l’encontre de la raison d’être de TypeScript. Plus votre programme est typé, plus vous obtiendrez de validation et d’outils, ce qui signifie que vous rencontrerez moins de bugs lorsque vous coderez.

Activer le drapeau noImplicitAny dans le fichier tsconfig.json provoquera une erreur sur toutes les variables dont le type est implicitement déduit comme étant Any.

8.2. Contrôles stricts de nullité

Par défaut, les valeurs telles que null et undefined peuvent être affectées à n’importe quel autre type. Cela peut faciliter l’écriture de certains codes, mais oublier de gérer les valeurs null et undefined est la cause d’innombrables bogues dans le monde — certains considèrent que c’est l’erreur à un milliard de dollars !

L’option strictNullChecks rend la gestion de null et undefined plus explicite et nous évite de nous soucier de savoir si nous avons oublié de gérer null et undefined. Mettre cette valeur à true empêchera par exemple aussi de laisser une variable déclarée comme une chaîne de caractère au type non défini.

D’un point de vue typage, null et undefined ne deviennent plus des valeurs acceptables pour les types existants.

Pour autoriser une valeur undefined, il faudra alors dire explicitement qu’une variable peut être une chaîne de caractère, par exemple, ou undefined en utilisant l’union de types :

name: string | undefined

Nous pourrons aussi utiliser la notion de propriété optionnelle si c’est un attribut d’une classe, d’une interface, un paramètre d’une fonction à l’aide de la notation suivante :

name?: string

Dans la classe MyFile, ajoutez un attribut isLinkedTo qui en tant que référence optionnelle vers un autre objet.

On vous donne la syntaxe dans l’extrait de code ci-dessous :

isLinkedTo?: FSElement; (1)
1 FSElement est le nom de l’interface créée précédemment.

Créez ensuite une méthode displayIsLinkedTo() qui affiche dans la console le nom de l’élément auquel est lié un fichier. Pour cela nous allons tester plusieurs syntaxes :

displayIsLinkedTo(): void {
    console.log('lié à :', this.isLinkedTo.name)
}
displayIsLinkedTo(): void {
    if (this.isLinkedTo !== undefined){
        console.log('lié à :', this.isLinkedTo.name)
    }
}
displayIsLinkedTo(): void {
  console.log('lié à :', this.isLinkedTo?.name);
}

Après un échange avec votre encadrant de TP, la lecture des références suivantes, présentez dans le compte rendu de TP avec vos mots les avantages et/ou les inconvénients de chacun des codes ci-dessus. Certains codes peuvent provoquer des erreurs à la compilation ou à l’exécution. Vous discuterez le pourquoi de ces erreurs.

9. Étape 8 : Placement de ce projet sur GitLab

Maintenant que le squelette est en place, nous allons placer ce code sur git.

Créez un blank project sur le GitLab de l’Université.

Désélectionnez Initialize repository with a README afin de garder un repo complètement vide. rQY9d7D

Configurez git sur votre machine :

# ⚠️ à remplacer avec votre nom et votre mail
git config --global user.name "Jean Jean"
git config --global user.email "jean.jean@univ-nantes.fr"

# Initialiser le repo local
git init --initial-branch=main

# Initialiser le repo distant sur lequel vous enverrez votre code
# ⚠️ mettre l'url en fonction de votre repo créé sur gitlab
git remote add origin git@gitlab.univ-nantes.fr:jean/tp-typescript.git

# Création d'un fichier README.adoc pour placer votre rapport
touch README.adoc #Pour votre future rapport

# Ajout des fichiers à suivre au sein de l'historique
git stage README.adoc src/** package.json tsconfig.json

# Commit des modifications
git commit -m 'Initial commit'

# Premier push vers le repo distant en fixant que la branche local courante est à placé vers la branch main à distance
git push --set-upstream origin main

# à partir de là, à la fin de chaque question, vous pouvez faire
git commit .
# afin de créer un nouveau snapshot de l'historique de vos fichiers
# et
git push
# pour envoyer l'historique vers GitLab
# ⚠️ si vous créez de nouveaux fichiers, il faudra les ajouter à l'index en utilisant la commande git add.
# N'oubliez pas de regarder par moment l'état de votre repo en faisant un git status.
# 👨🏽‍🏫:La qualité de l'utilisation de git sera évalué dans la note globale des TPs

10. Étape 9 : Et les tests unitaires dans tout cela ?

On va créer un test qui vérifie le comportement du getter de taille pour la classe Folder.

npm install --save-dev jest @types/jest @jest/globals ts-jest
npx ts-jest config:init

Cela crée le fichier de configuration pour le driver de tests (ici on utilise jest). Le fichier généré au départ ressemble à :

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

Dans le fichier package.json, ajoutez les lignes suivantes :

"scripts": {
    "test": "jest src/ --coverage"
  },

Cela permet de lancer le runner de test quand on lance la commande :

npm run test

Enfin, créez votre premier test qui test la classe Folder :

touch src/app.test.ts

Avec le contenu suivant :

import {describe, expect, test} from '@jest/globals';

describe('sum module', () => {
  test('adds 1 + 2 to equal 3', () => {
    expect(1+ 2).toBe(3);
  });
});

Pour importer la classe Folder, cette dernière doit être exportée. Dans le fichier app.ts, ajoutez le mot clé export devant les définitions (fonction, classe, etc.) que vous souhaitez exporter.

Par exemple pour exporter la classe MyFile, modifiez l’entête class MyFile{…​} en export class MyFile{…​}. Dans votre fichier de test src/app.test.ts, vous allez devoir importer les fonctions, classes, etc. exportées par src/app.ts. Pour cela, ajoutez une directive import au début de src/app.test.ts. Par exemple,

import {MyFile, Folder} from './app';
  1. Complétez le fichier de test avec des tests vérifiant que la valeur retournée par l’attribut size de la classe Folder est correcte.

  1. N’oubliez pas d’ajouter le fichier jest.config.js et vos fichiers de tests à votre repo git :

git add jest.config.js src/index.test.ts
git commit . -m 'add test'
git push

11. Étape 10 : Mise en place d’un linter

Un Linter est un outil d’analyse de code qui permet de détecter les erreurs et les problèmes de syntaxe.

Linter son code permet de le rendre :

  • Plus facile à relire et corriger en cas de besoin

  • Plus fiable

Les linters sont configurables : il suffit de décrire les modifications dans un fichier et de le partager aux membres de son équipe. C’est en partageant les configurations que tout le monde pourra travailler selon les mêmes règles. Si vous utilisez certaines technologies, il faut généralement les indiquer dans votre configuration, ou télécharger et inclure des règles de configuration, comme ça le linter pourra s’adapter à vos technologies.

Prenons un exemple : Si votre code est dédié à être exécuté sur un serveur, il ne doit pas utiliser des fonctionnalités du navigateur comme l’objet window par exemple. Dans votre configuration du Linter, vous pouvez indiquer que le code est dédié au serveur et lorsque vous écrirez window, le Linter va se manifester et vous lever une erreur.

Il est primordial de maintenir un code uniforme et homogène, pour cela l’utilisation d’un outil de formatting est essentiel ! Ainsi, il sera possible de détecter des bugs potentiels rapidement.

À noter que certains outils peuvent envoyer des alertes (plus ou moins gentilles) pour vous avertir de potentiels problèmes.

Dans la communauté JavaScript, il existe de très nombreux linters. Pour ce cours, nous utiliserons ESLint.

Vous devez installer ESLint. Comme ESLint ne supporte pas nativement TypeScript, vous devez donc également installer le module eslint-TypeScript-support:

npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint typescript

La commande ci-dessus ajoute ESLint et un analyseur qui permet à ESLint de comprendre TypeScript, ainsi que quelques règles spécifiques à TypeScript.

Ensuite, créez un fichier de configuration .eslintrc.js à la racine de votre projet et remplissez-le avec ce qui suit :

/* eslint-env node */
module.exports = {
  extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  root: true,
};

Pour exécuter ESLint, ouvrez un terminal à la racine de votre projet et exécutez la commande suivante :

npx eslint src/

N’hésitez pas à aller voir la documentation spécifique.