TypeScript étant une extension de JavaScript, tout code JavaScript valide est également un code TypeScript légal. Le principal attrait de TypeScript est l'introduction du typage statique qui permet une programmation plus rigoureuse et permet le repéragage d'erreurs en amont de l'exécution lors de l'étape de traduction de code TypeScript en JavaScript. Notons également que le typage statique pourrait théoriquement faciliter l'introduction d'optimisations pour l'exécution du code ; néanmoins la traduction en JavaScript nous fait perdre l'information sur les types. Les optimisations doivent donc être réalisées lors de l'exécution par l'interpréteur JavaScript.
Installation de TypeScript
TypeScript peut s'installer en utilisant le gestionnaire de paquetages npm de Node.js :
npm install -g typescript
Compilation en JavaScript
Avant son exécution, le code TypeScript doit être traduit en JavaScript. On utilise pour cela le compilateur tsc permettant une traduction en ECMAScript 3 au minimum (ce qui permet un support des navigateurs anciens).
Crééons un programme HelloWorld.ts en TypeScript :
class Greeter { private greetingMessage: string; constructor(greetingMessage: string) { this.greetingMessage = greetingMessage; } greet() { console.log(`Here is a greeting message: ${this.greetingMessage}`); } } class HelloWorldGreeter extends HelloWorld { constructor() { super("Hello World"); } } var greeter = new HelloWorldGreeter(); greeter.greet();
Remarquons que la notion de déclaration de champ de classe, naturelle pour la plupart des langages utilisant des classes (C++, Java, C#...) n'existe pas en JavaScript (on initialise les champs dans le constructeur sans les déclarer préalablement). La possibilité de déclarer ici un champ avec une visibilité est un ajout de TypeScript.
Nous pouvons exécuter directement ce programme depuis un shell à l'aide de ts-node :
ts-node HelloWorld.ts
Il est également possible de convertir ce programme TypeScript en JavaScript à l'aide de tsc qui produira un fichier .js (il est possible aussi d'ajouter des options de compilation) :
tsc HelloWorld.ts
On peut ensuite exécuter le fichier JavaScript depuis un shell avec node :
node HelloWorld.js
Notons qu'il est possible d'omettre la déclaration d'un champ de la classe si celui-ci est un argument du constructeur : pour cela nous préfixons l'argument du constructeur par sa visibilité (private, protected ou public). Nous pourrions réécrire Greeter ainsi :
class Greeter { constructor(private greetingMessage: string) {} void greet() { console.log(`Here is a greeting message: ${this.greetingMessage}`); } }
Types de base
TypeScript peut utiliser des types de base de JavaScript (object, number, boolean, string, undefined, null) et introduit des types additionnels. Nous examinons ici les types essentiels.
- any : il s'agit d'un désignateur de type universel destiné à indiquer que des variables peuvent prendre des valeurs de n'importe quel type. Le vérificateur de types du compilateur TypeScript est désactivé sur les variables any.
- void : désigne le type de retour de fonctions qui ne retournent rien. En pratique une variable void peut contenir les valeurs null ou undefined (il n'y a pas d'intérêt à en déclarer une)
- never : il s'agit d'un type de retour pour les fonctions qui ne retournent jamais (soit à cause d'une boucle infinie, soit parce qu'elles lèvent systématiquement une exception).
- array : indique un type tableau. TypeScript supporte les types paramétrés, ainsi une variable pointant un tableau de string pourra être déclarée ainsi let tab: string[] = ["a", "b", "c"];
- tuple : un tuple est un n-uplet de valeurs. Nous pouvons par exemple déclarer un triplet pour désigner une personne avec son nom, son prénom et son année de naissance: let person: [string, string, number] = ["John", "Doe", 2000];. Il est possible d'accéder ou de modifier une valeur comme s'il s'agissait d'un tableau : console.log(person[0]) affiche le prénom. Par contre le code person[2] = "test" ne compile pas car la troisième valeur du triplet est censée être un number et non un string.
- enum : un enum permet de déclarer un jeu limité de valeurs possibles. Ainsi nous pouvons déclarer un enum pour les jours de la semaine enum WeekDay { Monday, Tuesday, Wenesday, Thursday, Friday, Saturday, Sunday }. On peut ensuite déclarer une variable utilisant cet enum : let d: WeekDay = WeekDay.Sunday.
A ces types, s'ajoutent les types littéraux ainsi que toutes les interfaces et classes qui peuvent être déclarées dans des APIs ou définies par l'utilisateur.
Types litéraux
Un type litéral représente une valeur spécifique pour un string, un number...
Quelques exemples :
- type Status = 'present' | 'absent' defines a type with two possible values for a string, present and absent
- type CmpResult = -1 | 0 | 1 can be used for a type allowing only three values of number
Opérateur typeof
L'opérateur typeof permet d'obtenir le type déclaré d'une valeur.
Exemple:
- On créé un objet: let identity = {name: string, age: number}
- On peut ensuite initialiser un tableau pour contenir différentes identités: let identities: (typeof identity)[] = []
- Le tableau aura le même type que l'objet identity avec les champs indiqués ainsi que leur type
Classes
Depuis ECMAScript 2015, l'utilisation de classes est possible en JavaScript. TypeScript reprend cette possibilité avec la spécification de types. Le transpileur TypeScript est également capable de traduire le code utilisant des classes vers des versions antérieures de JavaScript sans syntaxe pour les classes.
Les classes ECMAScript utilisent des concepts similaires à ceux employés pour le langage Java. Les fonctionnalités suivantes sont supportées :
- une classe peut hériter d'au plus une classe avec extends
- une classe peut posséder un constructeur nommé constructor (on utilise super(...) pour appeler le constructeur de la classe ancêtre)
- une classe peut avoir des champs déclarés avec leur nom (et en TypeScript suivis par leur type) : fieldName: number
- les champs de la classe sont accessibles depuis les méthodes avec la référence this vers l'objet courant ; en JavaScript, l'utilisation de this est obligatoire contrairement à d'autres langages (tel que Java) où il peut être omis
- une méthode peut être redéfinie avec un appel possible à la méthode de la classe ancêtre : super.methodName(...)
- un membre (champ ou méthode) peut être déclarée avec le mot-clé static afin d'indiquer qu'il est lié à la classe plutôt qu'à l'objet (même concept qu'en Java) ; un membre static peut être accédé par le nom de sa classe (MyClass.staticMember)
TypeScript rajoute des possibilités par rapport à ECMAScript 2015 pour l'écriture de classes :
-
TypeScript possède une notion de visibilité des champs (comme Java) ;
- un membre de classe est par défaut de visibilité publique ;
- la visibilité peut être restreinte en protected (accessibilité aux classes dérivées)
- ou private (accessibilité restreinte à la classe elle-même)
- un champ peut être déclaré immuable (fourni lors de la construction) avec le mot-clé readonly (équivalent à final en Java)
- une classe et des méthodes peuvent être déclarées avec le mot clé abstract : la classe n'est pas instanciable et les méthodes n'ont pas d'implantation dans la classe (leur implantation doit être fournie dans une classe dérivée)
- une méthode peut être surchargée, i.e. il peut exister différentes méthodes de même nom mais avec des arguments de types différents. Ceci n'est pas possible en JavaScript où l'on ne peut spécifier des types
Interfaces
TypeScript supporte l'usage d'interfaces (contrairement à JavaScript). En TypeScript, les interfaces sont exclusivement utilisées pour le système de vérification de types mis en œuvre lors de la transpilation. Ainsi le code JavaScript généré ne contiendra aucune trace des interfaces écrites.
Il est possible de définir dans une interface des champs (contrairement à Java) ainsi que des méthodes. Les méthodes doivent rester purement abstraite : aucune implantation ne peut être fournie dans l'interface.
Voici un exemple d'interface proposable pour un point :
interface Point { readonly x: number; readonly y: number; getDistanceFromOrigin(): number; }
On peut ensuite implanter une classe utilisant cette interface :
class Point2D implements Point { constructor(public readonly x, public readonly y) {} getDistanceFromOrigin() { return Math.sqrt(this.x * this.x + this.y * this.y); } }
Le code JavaScript produit est complètement agnostique de l'interface utilisée.
Getter et setter
TypeScript (comme JavaScript) permet de définir un getter et un setter pour une propriété. La propriété s'utilise ensuite comme un champ classique. Voici un exemple de classe contenant un compteur :
class CounterClass { private _value: number = 0; get value(): number { return this._value; } set value(v: number) { this._value = v; } }
On peut ensuite utiliser la classe :
let cc = new CounterClass(); cc.value = 7; console.log(cc.value);
La logique des setters et getters peut être plus complexe que l'exemple présenté (calcul et stockage d'une valeur en cache par exemple. On peut ainsi rajouter dans CounterClass un getter squaredValue :
class CounterClass { ... private _squaredValue: number|undefined = undefined; get squaredValue(): number { if (this._squaredValue === undefined) this._squaredValue = this.value * this.value; return this._squaredValue; } }
Types nullables ou optionnels
Avant TypeScript 2, les valeurs null et undefined étaient considérées légales pour tous les types. Ce n'est plus le cas désormais. Ainsi le code suivant ne peut pas compiler :
class Person { name: string constructor(name: string) { this.name = name } } let p = new Person(null);
Pour que la valeur null soit acceptée pour un type, nous devons utiliser une union de type :
let v: string | null v = null
Lorsqu'un nom de variable ou champ est suivi d'un point d'interrogation, la valeur undefined est autorisée (mais pas null) :
let v?: string v = undefined // legal
Il est toujours possible de tester si une variable contient une valeur undefined ou null :
if (v === null) { // do something } else if (v == undefined) { // do another thing } else { // do the main thing }
Typage structurel
Comme vu précédemment, il est possible d'utiliser en TypeScript les types de base de JavaScript ainsi que des types correspondant à des classes ou interfaces définies par le développeur. Un intérêt de TypeScript est de proposer des possibilités de typage structurel. En typage structurel, les types ne sont pas définis par une classe mais par les membres qu'ils comportent pour les objets, les arguments qu'ils acceptent ou les types qu'ils retournent pour les fonctions.
Nous examinons sur cette page que certaines possibilités de typage de TypeScript ; cette page de la documentation de TypeScript décrit de manière plus exhaustives toutes les capacités de typage.
Types fonction
Un type de fonction est défini par les types des arguments qu'elle reçoit et le type de résultat qu'elle retourne. On peut par exemple définir une fonction recevant comme un arguments un tableau de nombres et un nombre et retournant un nombre :
// Fonction comptant le nombre d'occurrences d'un nombre dans un tableau let count: (number[], number) => number = (array, n) => { let result = 0; for (let value of array) if (value === n) result++; return result;
TypeScript offre la possibilité de nommer des types avec un alias :
type CounterFunction = (number[], number) => number let count: CounterFunction = (array, n) => { ... }
Écrivons maintenant une fonction prenant en paramètre une fonction et retournant le même type de fonction. Cette fonction aura pour rôle de réaliser une mémorisation des résultats dans un cache afin d'éviter des recalculs inutiles.
D'abord, nous définissons un type de fonction générique :
type FunctionOneArg<A, R> = (A) => R;
Ensuite écrivons la fonction de mémoisation :
const memoCache = new Map<FunctionOneArg, Map>(); function memoize<A, R>(originalFunc: FunctionOneArg<A, R>): FunctionOneArg<A, R> { if (memoCache.get(originalFunc) === undefined) { memoCache.set(originalFunc, new Map()); } let funcCache = memoCache.get(originalFunc); // type Map let newFunc: FunctionOneArg<A, R> = (x) => { let cached = funcCache.get(x); if (cached === undefined) { let computed = originalFunc(x); funcCache.set(x, computed); return computed; } else return <R>cached; // cast cached to R }; return newFunc; }
Nous pouvons ensuite écrire par exemple une fonction qui utilise la mémoisation (par exemple pour calculer des nombres de Fibonacci) :
let fibo = memoize((x: number) => if (x <= 1) return 1; else return fibo(x-1) + fibo(x-2); );
La fonction fibo possède un temps d'exécution linéaire grâce à la conservation en cache des nombres de Fibonacci calculés. Sans le cache, le temps d'exécution serait exponentiel selon le rang.
Typage canard (duck typing)
Le poète américain James Whitcomb Riley déclara un jour que s'il voyait un oiseau marcher comme un canard, nager comme un canard et cancaner comme un canard, il appelerai cet oiseau un canard. Appliquée au typage d'objets, cet aphorisme nous conduit à considérer que si un objet B possède les mêmes membres qu'un objet A (plus d'autres éventuellement), B peut être considéré d'un sous-type du type de A. Il n'est donc pas nécessaire de déclarer explicitement que la classe de B hérite de la classe de A.
Ce principe de duck typing s'applique au système de typage de TypeScript. Définissons par exemple une interface disposant d'un champ birthYear :
interface Born { birthYear: number; }
Nous pouvons ensuite créer une classe Person implantant cette interface :
class Person implements Born { constructor(public firstName: string, public lastName: string, public birthYear: number) {} }
Nous pouvons par ailleurs écrire une fonction permettant de calculer l'âge d'un objet implantant l'interface Born :
computeAge(b: Born): number { let currentYear = (new Date()).getFullYear(); return currentYear - b.birthYear; }
Ecrivons maintenant une classe pour représenter un langage de programmation :
class ProgrammingLanguage { constructor(public name: string, public birthYear: number) {} }
Nous pouvons également appeler computeAge avec un ProgrammingLanguage bien que celui-ci n'implante pas l'interface Born. En effet, TypeScript considère qu'un objet ProgrammingLanguage ayant un champ birthYear est d'un sous-type de Born :
let ts = new ProgrammingLanguage("TypeScript", 2012); console.log(`Age of ${ts.name}: ${computeAge(ts)}`);
Il est possible aussi de définir un type sans le nommer ; nous aurions pu par exemple typer ainsi l'argument b de computeAge :
computeAge(b: {birthYear: number}): number { ... }
Type union
Nous pouvons définir une variable ou membre de classe avec un type union lorsque sa valeur peut être d'un type ou d'un autre lors de l'exécution. Définissons par exemple un arbre binaire de nombres. Les feuilles sont des nombres tandis que les noeuds internes possèdent des références vers deux enfants qui peuvent être des feuilles (nombre), d'autres nœuds ou alors null si l'enfant n'existe pas (un nœud peut possèder un ou deux enfants). Nous pouvons définir le type Node ainsi :
interface Node { leftChild: Node | number; rightChild: Node | number | null; }
Nous pouvons écrire une fonction chargée de retourner un tableau avec tous les nombres d'un arbre obtenus par parcours en profondeur :
function collectNodeElements(node: Node, destination: number[]) { for (let child of [node.leftChild, node.rightChild]) { if (child instanceof Node) collectNodeElements(child, destination); else if (typeof(child) === "number") destination.push(child); } } function collectNodeElements(node: Node): number[] { let a: number[] = []; collectNodeElements(node, a); return a; }
Lorsqu'une variable est de type union, l'utilisation sans cast de cette variable ne permet que d'utiliser les membres communs à tous les types participant à l'union. Pour accéder à un membre d'un des types de l'union, un cast est préalablement nécessaire. Avec TypeScript, un bloc conditionnel testant avec instanceof ou typeof le type de la variable est suffisant pour la caster automatiquement.
Type intersection
Le type intersection permet de créer un type qui cumule toutes les propriétés des types intersectés. La documentation TypeScript nous présente une exemple d'utilisation d'un type intersection afin de fusionner deux objets différents en un seul (avec les membres des deux) :
function extend<T, U>(first: T, second: U): T & U { let result = <T & U>{}; for (let id in first) { (<any>result)[id] = (<any>first)[id]; } for (let id in second) { if (!result.hasOwnProperty(id)) { (<any>result)[id] = (<any>second)[id]; } } return result; } class Person { constructor(public name: string) { } } interface Loggable { log(): void; } class ConsoleLogger implements Loggable { log() { // ... } } var jim = extend(new Person("Jim"), new ConsoleLogger()); // jim est de type Person & Loggable var n = jim.name; jim.log();
Inférence de type
La plupart des langages de programmation modernes proposent un système d'inférence de types. Le compilateur détermine le type d'une variable en fonction de l'affectation réalisée lors de la déclaration d'une variable.
Réalisons une déclaration avec une affectation puis une réaffectation :
let [name, year] = ["toto", 2000]; year = "titi";
Le précédent code est valide en ECMAScript 2015 cependant le compilation TypeScript produira une erreur ayant déterminé que year est un number, il n'est pas possible de lui affecter ensuite une valeur de type string. Toutefois cet obstacle pourrait être levé ainsi :
let [name, year]: [string, any] = ["toto", 2000]; year = "titi";
ou alors en utilisant une union de type pour year :
let [name, year]: [string, string|number] = ["toto", 2000]; year = "titi";
D'une manière générale, utiliser le type any est une méthode simple pour éliminer des erreurs de typage produites par TypeScript mais on perd alors le seul intérêt de TypeScript : la vérification des types.
Ecrivons maintenant une fonction retournant un objet de type Cat ou Dog :
class Cat { miaow() { console.log("miaow"); } } class Dog { waf() { console.log("waf"); } } function catOrDog() { return (Math.random < 0.5) ? new Cat() : new Dog(); } let animal = catOrDog(); if (animal instanceof Cat) animal.miaow(); else if (animal instanceof Dog) animal.waf();
Ici TypeScript déduit que le type de retour de la fonction catOrDog est l'union de types Cat | Dog. Ainsi animal est de ce type union. Par la suite, l'utilisation de instanceof permet localement de restreindre le type de animal en Cat ou Dog. Si nous avions appelé animal.miaow() sans test avec instanceof Cat, TypeScript aurait relevé une erreur de type.
TypeScript peut aussi travailler avec des fichiers JavaScript classiques et réaliser lorsque c'est possible une inférence de type. Il est aussi possible de rajouter des spécifications de type dans un fichier JavaScript (.js) en utilisant un commentaire JSDoc (qui sera ignoré par un interpréteur JavaScript) :
/** @type {number} */ let x; x = 0;
Les modules
Une faiblesse de JavaScript a longtemps été la gestion de l'espace de nommage et la difficulté de découper un projet en modules avec des dépendances. JavaScript est à l'origine un langage de script qui n'était pas conçu pour réaliser des projets ambitieux comportant des dizaines (voire plus) de milliers de ligne de code. Des bibliothèques externes telles que CommonJS ou require.js ont proposé des systèmes de gestion de modules. Depuis ECMAScript 2015 et avec TypeScript, les modules sont supportés nativement.
Un fichier TypeScript est considéré soit comme un script et il utilise alors l'espace de nommage principal, soit comme un module s'il possède au moins une déclaration import ou export.
Par défaut, tout symbole non exporté d'un module n'est pas accessible depuis un autre module. On exporte un symbole (variable, classe, type...) en préfixant sa déclaration par export :
const MY_PI = 3.14; export interface Shape2D { } export class Circle extends Shape2D { radius: number; constructor(radius: number) { this.radius = radius; } getArea(): number { return radius * MY_PI * MY_PI; } }
Pour l'exemple précédent, la constante MY_PI n'est pas exporté. En revanche Circle est exporté. Si nous crééons un autre fichier dans le même répertoire, nous pouvons importer cette classe :
import { Circle } from "./circle.ts"; export class Cone { base: Circle; height: number; constructor(base: Circle, height: number) { this.base = base; this.height = height; } getVolume(): number { return this.base.getArea() * this.height / 3; } }
Il est possible aussi d'importer plusieurs symboles d'un module en les listant entre accolades dans la clause import ; par exemple si l'on souhaite importer Circle et Shape2D de circle.ts :
import { Shape2D, Circle } from "./circle.ts";
Si un module n'est censé exporter qu'un seul symbole, il est possible de le déclarer comme export par défaut avec le mot-clé default :
export default class Circle { ... }
On peut ensuite l'importer sous le nom souhaité depuis un autre module (ici cone.ts) :
import MyCircle from "./circle.ts" export class Cone { base: MyCircle; ... }
Si on souhaite importer un module avec tous ses (nombreux) symboles, on peut demander une importation joker :
import * as circleModule from "./circle.ts"
On peut ensuite accéder aux classes circleModule.Shape2D et circleModule.Circle
On privilégiera avec TypeScript l'utilisation de modules plutôt que de namespaces. L'utilisation d'un environnemment de développement compatible avec TypeScript facilite l'export et l'import des modules (avec plus ou moins d'automatisme).
Lors de la compilation de modules, TypeScript traduisant par défaut le code en ECMAScript 3, une bibiliothèque de support de modules est utilisée. Par exemple si on souhaite compiler le module cone en utilisant la bibliothèque de modules commonjs :
tsc --module commonjs cone.ts