TypeScript et la programmation fonctionnelle
Introduction
Depuis plusieurs années, la programmation fonctionnelle est revenue sur le devant de la scène. Plébiscité par les développeurs web, ce paradigme a souvent été cantonné au domaine scientifique et académique. Ce style de programmation est maintenant utilisé pour le développement d’applications web de grande envergure et il a été particulièrement mis en avant par Facebook, qui est connu pour ces projets utilisant la programmation fonctionnelle (notamment React, Immutable.js, ReScript...).
TypeScript de son côté est un langage dit multiparadigme. Les développeurs peuvent donc choisir le style de programmation qu’ils souhaitent utiliser pour développer leurs projets. La programmation orientée objet (cf. chapitre La programmation orientée objet) est l’un des styles de programmation les plus populaires en TypeScript, cependant le langage dispose aussi de capacités fonctionnelles qui en font un choix intéressant lorsque l’on veut développer un projet avec ce paradigme. Toutefois, TypeScript est considéré comme un langage partiellement fonctionnel, car il n’implémente pas certaines capacités propres au monde fonctionnel. En revanche, sa nature multiparadigme fait qu’il est possible de mixer les styles de programmation entre eux.
La programmation fonctionnelle...
Fonction pure et impure
La première chose à apprendre lorsque l’on débute en programmation fonctionnelle est la notion de pureté. Une fonction est dite pure si elle :
-
Ne provoque pas de side effect (effet de bord en français).
-
Renvoie la même valeur pour les mêmes paramètres.
Ces deux règles permettent d’augmenter la fiabilité d’un programme. Une fonction pure ne provoque pas d’impacts non maîtrisés lors de l’exécution, car elle garantit que son fonctionnement ne peut pas altérer celui d’une autre fonction.
Pour ne pas provoquer d’effet de bord, une fonction ne doit en aucun cas changer la valeur d’une variable. Une fonction est donc dite impure si elle mute une variable globale.
Exemple :
let value = 1295;
const add = (number: number)=> {
value += number;
};
add(42);
// Log: 1337
console.log(value);
Dans cet exemple, la fonction add incrémente la variable globale value avec le paramètre number qui lui a été passé. Cette incrémentation provoque un effet de bord global et peut donc avoir un impact sur le fonctionnement d’une autre fonction qui utilise cette variable. Ce type d’impact peut être la source d’un bug dans une application et doit être considéré comme...
Immutabilité
Dans la section précédente, la définition d’une fonction pure reposait principalement sur la façon dont elle a été implémentée. Cependant, il est possible de traiter les effets de bord comme des erreurs lors de la compilation avec TypeScript. Pour cela, il est nécessaire de déclarer les variables de manière immutable. La notion de mutation a déjà été abordée précédemment avec l’utilisation des mots-clés var, let et const (cf. chapitre Types et instructions basiques). L’utilisation de const pour la déclaration d’une variable empêche celle-ci d’être réassignée par la suite. La référence de cette variable devient donc immutable, sa valeur ne peut alors plus être modifiée.
En programmation fonctionnelle, il est préférable d’utiliser des variables immutables, car elles empêchent la création d’effet de bord et aident les développeurs lors de l’implémentation de fonction pure. De plus, l’utilisation de l’immutabilité permet de faciliter l’analyse des variables lors des sessions de débogage. En effet, les valeurs ne pouvant pas être modifiées, les mutations seront assignées dans de nouvelles variables. Il devient alors facile de comparer la variable d’origine et la version modifiée qui a été retournée par une fonction.
Pour rappel, voici un exemple de déclaration d’une variable immutable avec le mot-clé const :
const firstName: string = "Evelyn";
// Compilation Error TS2588:
// Cannot assign to 'firstName' because it is a constant.
firstName = "John";
Le mot-clé const pose cependant un problème, car lors de son utilisation pour la déclaration d’une variable, seule la référence est immutable. Cela veut dire que dans le cas d’un objet, ses propriétés sont mutables. En conséquence, il est possible de modifier les valeurs contenues dans un objet.
Exemple :
const employe = {
prenom: "Martin",
nom: "Dupont",
salaire: 2000
};
// The salary...
Itération
L’itération d’un tableau dans un contexte où les effets de bord sont interdits peut être compliquée. Le problème est facilement démontrable avec un exemple d’une fonction effectuant le calcul d’une somme.
Exemple :
const sum = (numbers: ReadonlyArray<number>) => {
let result = 0;
numbers.forEach((number: number) => {
result += number;
});
return result;
};
Dans cet exemple, il est obligatoire de déclarer une variable mutable afin de recalculer la somme à chaque itération. La fonction est donc impure si l’on interdit les effets de bord locaux. Pour calculer la somme dans ce cas précis, il est préférable de ne pas utiliser la méthode forEach (idem pour les boucles for et while). Bannir l’utilisation de boucles en programmation entraîne la question suivante : comment itérer sur un tableau sans utiliser de méthode ou d’opérateur ? La réponse est d’utiliser la récursivité !
Ce concept est relativement simple à comprendre : une fonction est dite récursive à partir du moment où elle se rappelle elle-même. Cependant, il est nécessaire de prévoir dans la fonction une terminaison afin qu’elle ne s’exécute...
Conditions
Tout comme les itérations, l’utilisation de conditions peut être compliquée lorsque les mutations sont interdites. La fonction divide, utilisée précédemment dans ce chapitre, illustre bien le fait que les conditions peuvent forcer à utiliser des mutations.
Une première technique permettant d’éviter les mutations lors de l’utilisation d’une condition est l’utilisation d’un return en fin de condition. Cela permet de retourner une valeur sans modifier une variable locale. En programmation fonctionnelle, pour une meilleure compacité de code, il est fréquent d’utiliser des opérateurs ternaires. En reprenant la fonction divide, un opérateur ternaire permet d’éviter de créer une mutation.
Exemple :
const divide = (number1: number, number2: number) => {
return number2 !== 0
? { success: true, result: number1 / number2 }
: { success: false, result: NaN };
};
const result1 = divide(200, 2);
// Log: { success: true, result: 100 }
console.log(result1);
const result2 = divide(200, 0);
// Log: { success: false, result: NaN }
console.log(result2);
Dans le cas de conditions multiples (ou imbriquées), les opérateurs ternaires peuvent être moins pratiques...
Fonction partielle
Les fonctions partielles permettent de mettre en œuvre le concept d’application partielle. C’est une technique qui permet de réduire le nombre de paramètres d’une fonction afin d’en produire une autre ayant moins d’arguments.
L’intérêt de l’application partielle est de permettre de définir des fonctions incomplètes ayant un haut niveau de réutilisabilité par la suite.
Exemple :
interface Employee {
firstName: string;
lastName: string;
salary: number;
}
const increaseSalary = (percent: number) => {
return (employee: Readonly<Employee>) => {
const increase = employee.salary * (percent / 100);
const salary = employee.salary + increase;
return {
...employee,
salary
};
};
};
const increaseSalaryByTwoPercent = increaseSalary(2);
const evelyn = {
firstName: "Evelyn",
lastName: "Miller",
salary: 2000
};
const increasedEvelyn = increaseSalaryByTwoPercent(evelyn);
// Log: { firstName: 'Evelyn', lastName: 'Miller', salary: 2040 }
console.log(increasedEvelyn); ...
Currying
En programmation fonctionnelle, le Currying (ou Curryfication en français) est une technique permettant de convertir une fonction ayant plusieurs arguments, en un ensemble de fonctions ayant chacune un argument unique. Le concept de Currying est souvent confondu avec celui de l’application partielle. Cette dernière permet de générer une nouvelle fonction prenant moins d’arguments que l’originale, tandis que le Currying produit autant de fonctions que de paramètres présents dans la fonction originale.
Pour illustrer l’utilisation du Currying, prenons l’exemple d’une fonction permettant de créer un objet de type Person.
Exemple :
interface Person {
firstName: string;
lastName: string;
}
const create = (firstName: string, lastName: string) => {
const person: Person = {
firstName,
lastName
};
return person;
};
// Log: { firstName: 'Evelyn', lastName: 'Miller' }
const person = create("Evelyn", "Miller");
console.log(person);
Cette fonction peut être écrite d’une manière permettant de l’utiliser comme une fonction curryfiée.
Exemple :
const create = (firstName: string) => {
return (lastName: string)...
Pattern Matching
Beaucoup de langages orientés fonctionnels permettent d’utiliser le concept de Pattern Matching (Filtrage par motif en français). Il permet de vérifier si une valeur correspond à une règle (Pattern). Si elle est respectée, il est alors possible d’exécuter un bloc de code renvoyant une valeur. Une fonction de matching va récupérer le paramètre en entrée pour l’analyser via un ensemble de patterns. Ces derniers permettront à la fonction de matching de définir quel bloc de code va être exécuté.
Le concept de Pattern Matching n’est malheureusement pas présent de base dans TypeScript. Le langage ne dispose pas encore des capacités syntaxiques permettant de le mettre en œuvre. Actuellement, le Pattern Matching est en attente de normalisation dans ECMAScript.
Il est possible d’implémenter le Pattern Matching en utilisant une fonction anonyme auto-exécutée ayant un retour. Cette fonction acceptera une valeur en paramètre et utilisera des conditions (if, else...if, else ou switch) afin de l’analyser et d’exécuter un bloc de code en conséquence.
Exemple :
const salaries: ReadonlyArray<number> = [1700, 2250, 2000, 1850];
const increasedSalary = (salary: number) => {
return ((s) => {
if (s <= 1800)...
Composition de fonctions
Un des intérêts de l’application partielle est qu’elle permet ensuite aux fonctions d’être combinées les unes avec les autres, notamment via l’utilisation des concepts de pipe et de composition.
TypeScript ne dispose pas de capacités syntaxiques permettant l’utilisation de composition ou de pipe. Pour ce dernier, un opérateur de pipeline est en cours de normalisation au TC39.
Les pipes permettent de chaîner les appels de fonction les uns après les autres. La fonction suivante sera alors appelée avec la valeur de retour de la précédente.
Exemple :
enum Gender {
Male,
Female
}
interface Employee {
firstName: string;
lastName: string;
salary: number;
gender: Gender;
}
const employees = [
{
firstName: "Bryan",
lastName: "Hill",
salary: 1700,
gender: Gender.Male
},
{
firstName: "Evelyn",
lastName: "Miller",
salary: 2250,
gender: Gender.Female
},
{
firstName: "John",
lastName: "Riley", ...
Pour aller plus loin…
Ce chapitre n’est qu’un aperçu de l’utilisation de la programmation fonctionnelle en TypeScript. Ce paradigme est très intéressant et certaines de ses capacités de base peuvent facilement être utilisées en combinaison de la programmation orientée objet. Toutefois, pour aller plus loin il est nécessaire de s’intéresser à la théorie des catégories pour comprendre les types de données algébriques (Functor, Monad, Monoid…).
Certaines bibliothèques open source permettent aussi d’aller plus loin en implémentant certains concepts vus au travers de ce chapitre :
-
Immutable.js : bibliothèque créée par Facebook qui fournit des structures de données immutables implémentant le Structural Sharing (via l’utilisation de Hash maps tries, un tableau d’association permettant le partage de structure de données).
-
Ramda: bibliothèque fournissant des fonctions utilitaires pour mettre en œuvre la programmation fonctionnelle.
-
fp-ts: bibliothèque inspirée par Haskell et Scala (deux langages de programmation fonctionnelle). Elle contient un ensemble de types, implémentations et abstractions parmi les plus utiles en programmation fonctionnelle (exemple : Maybe, Option, Functor, Either…).