Explorez le concept avancé des Types de Kind Supérieur (HKT) en TypeScript. Découvrez leur définition, leur importance et comment les émuler pour un code puissant, abstrait et réutilisable.
Maîtriser les Abstractions Avancées : Une Plongée au Cœur des Types de Kind Supérieur en TypeScript
Dans le monde de la programmation à typage statique, les développeurs cherchent constamment de nouvelles manières d'écrire du code plus abstrait, réutilisable et typé de manière sûre. Le puissant système de types de TypeScript, avec des fonctionnalités comme les génériques, les types conditionnels et les types mappés, a apporté un niveau remarquable de sécurité et d'expressivité à l'écosystème JavaScript. Cependant, il existe une frontière de l'abstraction au niveau des types qui reste juste hors de portée native de TypeScript : les Types de Kind Supérieur (HKT).
Si vous vous êtes déjà retrouvé à vouloir écrire une fonction qui soit générique non seulement sur le type d'une valeur, mais sur le contenant qui héberge cette valeur — comme Array
, Promise
, ou Option
— alors vous avez déjà ressenti le besoin des HKT. Ce concept, emprunté à la programmation fonctionnelle et à la théorie des types, représente un outil puissant pour créer des bibliothèques véritablement génériques et composables.
Bien que TypeScript ne supporte pas nativement les HKT, la communauté a conçu des moyens ingénieux de les émuler. Cet article vous emmènera dans une plongée approfondie dans le monde des Types de Kind Supérieur. Nous explorerons :
- Ce que sont conceptuellement les HKT, en partant des principes de base avec les kinds.
- Pourquoi les génériques standards de TypeScript sont insuffisants.
- Les techniques les plus populaires pour émuler les HKT, en particulier l'approche utilisée par des bibliothèques comme
fp-ts
. - Les applications pratiques des HKT pour construire des abstractions puissantes comme les Foncteurs, les Applicatifs et les Monades.
- L'état actuel et les perspectives d'avenir des HKT en TypeScript.
C'est un sujet avancé, mais sa compréhension changera fondamentalement votre façon de penser l'abstraction au niveau des types et vous permettra d'écrire un code plus robuste et élégant.
Comprendre les Fondations : Génériques et Kinds
Avant de pouvoir nous lancer dans les kinds supérieurs, nous devons d'abord avoir une solide compréhension de ce qu'est un "kind". En théorie des types, un kind est le "type d'un type". Il décrit la forme ou l'arité d'un constructeur de type. Cela peut paraître abstrait, alors ancrons-le dans des concepts familiers de TypeScript.
Kind *
: Les Types Propres
Pensez aux types simples et concrets que vous utilisez tous les jours :
string
number
boolean
{ name: string; age: number }
Ce sont des types "entièrement formés". Vous pouvez créer une variable de ces types directement. Dans la notation des kinds, on les appelle des types propres, et ils ont le kind *
(prononcé "star" ou "type"). Ils n'ont besoin d'aucun autre paramètre de type pour être complets.
Kind * -> *
: Les Constructeurs de Types Génériques
Considérez maintenant les génériques de TypeScript. Un type générique comme Array
n'est pas un type propre en soi. Vous ne pouvez pas déclarer une variable let x: Array
. C'est un modèle, un plan, ou un constructeur de type. Il a besoin d'un paramètre de type pour devenir un type propre.
Array
prend un type (commestring
) et produit un type propre (Array
).Promise
prend un type (commenumber
) et produit un type propre (Promise
).type Box
prend un type (comme= { value: T } boolean
) et produit un type propre (Box
).
Ces constructeurs de types ont un kind de * -> *
. Cette notation signifie qu'ils sont des fonctions au niveau des types : ils prennent un type de kind *
et retournent un nouveau type de kind *
.
Kinds Supérieurs : (* -> *) -> *
et Au-delĂ
Un type de kind supérieur est donc un constructeur de type qui est générique sur un autre constructeur de type. Il opère sur des types d'un kind supérieur à *
. Par exemple, un constructeur de type qui prendrait quelque chose comme Array
(un type de kind * -> *
) comme paramètre aurait un kind comme (* -> *) -> *
.
C'est là que les capacités natives de TypeScript atteignent leurs limites. Voyons pourquoi.
La Limitation des Génériques Standards de TypeScript
Imaginons que nous voulions écrire une fonction map
générique. Nous savons comment l'écrire pour un type spécifique comme Array
:
function mapArray<A, B>(arr: A[], f: (a: A) => B): B[] {
return arr.map(f);
}
Nous savons aussi comment l'écrire pour notre type personnalisé Box
:
type Box<A> = { value: A };
function mapBox<A, B>(box: Box<A>, f: (a: A) => B): Box<B> {
return { value: f(box.value) };
}
Remarquez la similarité structurelle. La logique est identique : prendre un contenant avec une valeur de type A
, appliquer une fonction de A
vers B
, et retourner un nouveau contenant de la mĂŞme forme mais avec une valeur de type B
.
L'étape naturelle suivante est d'abstraire le contenant lui-même. Nous voulons une seule fonction map
qui fonctionne pour n'importe quel contenant supportant cette opération. Notre première tentative pourrait ressembler à ceci :
// CECI N'EST PAS DU TYPESCRIPT VALIDE
function map<F, A, B>(container: F<A>, f: (a: A) => B): F<B> {
// ... comment implémenter cela ?
}
Cette syntaxe échoue immédiatement. TypeScript interprète F
comme une variable de type régulière (de kind *
), et non comme un constructeur de type (de kind * -> *
). La syntaxe F
est illégale car vous ne pouvez pas appliquer un paramètre de type à un autre type comme un générique. C'est le problème fondamental que l'émulation des HKT cherche à résoudre. Nous avons besoin d'un moyen de dire à TypeScript que F
est un substitut pour quelque chose comme Array
ou Box
, et non string
ou number
.
Émuler les Types de Kind Supérieur en TypeScript
Puisque TypeScript manque d'une syntaxe native pour les HKT, la communauté a développé plusieurs stratégies d'encodage. L'approche la plus répandue et la plus éprouvée implique l'utilisation d'une combinaison d'interfaces, de recherches de type et d'augmentation de module. C'est la technique fameusement utilisée par la bibliothèque fp-ts
.
La Méthode par URI et Recherche de Type
Cette méthode se décompose en trois composants clés :
- Le type
Kind
: Une interface porteuse générique pour représenter la structure HKT. - Les URI : Des littéraux de chaîne de caractères uniques pour identifier chaque constructeur de type.
- Une correspondance (mapping) URI-vers-Type : Une interface qui relie les URI de chaîne de caractères à leurs définitions de constructeur de type réelles.
Construisons-la étape par étape.
Étape 1 : L'interface `Kind`
D'abord, nous définissons une interface de base à laquelle tous nos HKT émulés se conformeront. Cette interface sert de contrat.
export interface HKT<URI, A> {
readonly _URI: URI;
readonly _A: A;
}
Disséquons cela :
_URI
: Cette propriété contiendra un type littéral de chaîne de caractères unique (par ex.,'Array'
,'Option'
). C'est l'identifiant unique pour notre constructeur de type (leF
dans notre imaginaireF
). Nous utilisons un tiret bas au début pour signaler que c'est uniquement pour une utilisation au niveau des types et n'existera pas à l'exécution._A
: C'est un "type fantôme". Il contient le paramètre de type de notre contenant (leA
dansF
). Il ne correspond pas à une valeur à l'exécution mais est crucial pour que le vérificateur de types suive le type interne.
Parfois, vous verrez cela écrit comme Kind
. Le nommage n'est pas critique, mais la structure l'est.
Étape 2 : La Correspondance URI-vers-Type
Ensuite, nous avons besoin d'un registre central pour dire à TypeScript à quel type concret un URI donné correspond. Nous y parvenons avec une interface que nous pouvons étendre en utilisant l'augmentation de module.
export interface URItoKind<A> {
// Ceci sera peuplé par différents modules
}
Cette interface est intentionnellement laissée vide. Elle sert de point d'ancrage. Chaque module qui veut définir un type de kind supérieur y ajoutera une entrée.
Étape 3 : Définir un Type Utilitaire `Kind`
Maintenant, nous créons un type utilitaire qui peut résoudre un URI et un paramètre de type pour retrouver un type concret.
export type Kind<URI extends keyof URItoKind<any>, A> = URItoKind<A>[URI];
Ce type Kind
opère la magie. Il prend un URI
et un type A
. Il recherche ensuite l'URI
dans notre correspondance `URItoKind` pour récupérer le type concret. Par exemple, `Kind<'Array', string>` devrait se résoudre en Array
. Voyons comment nous y parvenons.
Étape 4 : Enregistrer un Type (par ex., `Array`)
Pour que notre système reconnaisse le type natif Array
, nous devons l'enregistrer. Nous le faisons en utilisant l'augmentation de module.
// Dans un fichier comme `Array.ts`
// D'abord, déclarez un URI unique pour le constructeur de type Array
export const URI = 'Array';
declare module './hkt' { // Suppose que nos définitions HKT sont dans `hkt.ts`
interface URItoKind<A> {
readonly [URI]: Array<A>;
}
}
Analysons ce qui vient de se passer :
- Nous avons déclaré une constante de chaîne de caractères unique
URI = 'Array'
. Utiliser une constante nous évite les fautes de frappe. - Nous avons utilisé
declare module
pour rouvrir le module./hkt
et augmenter l'interfaceURItoKind
. - Nous y avons ajouté une nouvelle propriété : `readonly [URI]: Array`. Cela signifie littéralement : "Quand la clé est la chaîne 'Array', le type résultant est
Array
."
Maintenant, notre type Kind
fonctionne pour Array
! Le type `Kind<'Array', number>` sera résolu par TypeScript comme URItoKind
, qui, grâce à notre augmentation de module, est Array
. Nous avons réussi à encoder Array
comme un HKT.
Assembler le Tout : Une Fonction `map` Générique
Avec notre encodage HKT en place, nous pouvons enfin écrire la fonction map
abstraite dont nous rêvions. La fonction elle-même ne sera pas générique ; à la place, nous définirons une interface générique nommée Foncteur
qui décrit tout constructeur de type sur lequel on peut "mapper".
// Dans `Functor.ts`
import { HKT, Kind, URItoKind } from './hkt';
export interface Functor<F extends keyof URItoKind<any>> {
readonly URI: F;
readonly map: <A, B>(fa: Kind<F, A>, f: (a: A) => B) => Kind<F, B>;
}
Cette interface Foncteur
est elle-même générique. Elle prend un paramètre de type, F
, qui est contraint à être l'un de nos URI enregistrés. Elle a deux membres :
URI
: L'URI du foncteur (par ex.,'Array'
).map
: Une méthode générique. Remarquez sa signature : elle prend un `Kind` et une fonction, et retourne un `Kind `. C'est notre map
abstrait !
Maintenant, nous pouvons fournir une instance concrète de cette interface pour Array
.
// De retour dans `Array.ts`
import { Functor } from './Functor';
// ... configuration HKT précédente pour Array
export const array: Functor<typeof URI> = {
URI: URI,
map: <A, B>(fa: Array<A>, f: (a: A) => B): Array<B> => fa.map(f)
};
Ici, nous créons un objet array
qui implémente Functor<'Array'>
. L'implémentation de map
est simplement un wrapper autour de la méthode native Array.prototype.map
.
Enfin, nous pouvons écrire une fonction qui utilise cette abstraction :
function doSomethingWithFunctor<F extends keyof URItoKind<any>>(
functor: Functor<F>
) {
return <A, B>(fa: Kind<F, A>, f: (a: A) => B): Kind<F, B> => {
return functor.map(fa, f);
};
}
// Utilisation :
const numbers = [1, 2, 3];
const double = (n: number) => n * 2;
// Nous passons l'instance de l'array pour obtenir une fonction spécialisée
const mapForArray = doSomethingWithFunctor(array);
const doubledNumbers = mapForArray(numbers, double); // [2, 4, 6]
console.log(doubledNumbers); // Le type est correctement inféré comme number[]
Ça fonctionne ! Nous avons créé une fonction doSomethingWithFunctor
qui est générique sur le type de contenant F
. Elle ne sait pas si elle travaille avec un Array
, une Promise
, ou une Option
. Elle sait seulement qu'elle a une instance de Foncteur
pour ce contenant, ce qui garantit l'existence d'une méthode map
avec la bonne signature.
Applications Pratiques : Construire des Abstractions Fonctionnelles
Le Foncteur
n'est qu'un début. La motivation première pour les HKT est de construire une riche hiérarchie de classes de types (interfaces) qui capturent des patrons de calcul courants. Examinons deux autres abstractions essentielles : les Foncteurs Applicatifs et les Monades.
Foncteurs Applicatifs : Appliquer des Fonctions dans un Contexte
Un Foncteur vous permet d'appliquer une fonction normale à une valeur à l'intérieur d'un contexte (par ex., `map(valeurDansContexte, fonctionNormale)`). Un Foncteur Applicatif (ou simplement Applicatif) va plus loin : il vous permet d'appliquer une fonction qui est aussi à l'intérieur d'un contexte à une valeur dans un contexte.
La classe de type Applicatif étend Foncteur et ajoute deux nouvelles méthodes :
of
(aussi connu sous le nom de `pure`) : Prend une valeur normale et l'élève dans le contexte. PourArray
,of(x)
serait[x]
. PourPromise
,of(x)
seraitPromise.resolve(x)
.ap
: Prend un contenant avec une fonction `(a: A) => B` et un contenant avec une valeur `A`, et retourne un contenant avec une valeur `B`.
import { Functor } from './Functor';
import { Kind, URItoKind } from './hkt';
export interface Applicative<F extends keyof URItoKind<any>> extends Functor<F> {
readonly of: <A>(a: A) => Kind<F, A>;
readonly ap: <A, B>(fab: Kind<F, (a: A) => B>, fa: Kind<F, A>) => Kind<F, B>;
}
Quand est-ce utile ? Imaginez que vous avez deux valeurs dans un contexte, et vous voulez les combiner avec une fonction Ă deux arguments. Par exemple, vous avez deux champs de formulaire qui retournent une `Option
// Supposons que nous avons un type Option et son instance Applicative
const name: Option<string> = some('Alice');
const age: Option<number> = some(30);
const createUser = (name: string) => (age: number) => ({ name, age });
// Comment appliquer createUser Ă name et age ?
// 1. Élever la fonction curryfiée dans le contexte Option
const curriedUserInOption = option.of(createUser);
// curriedUserInOption est de type Option<(name: string) => (age: number) => User>
// 2. `map` ne fonctionne pas directement. Nous avons besoin de `ap` !
const userBuilderInOption = option.ap(option.map(curriedUserInOption, f => f), name);
// C'est lourd. Une meilleure façon :
const userBuilderInOption2 = option.map(name, createUser);
// userBuilderInOption2 est de type Option<(age: number) => User>
// 3. Appliquer la fonction-dans-un-contexte à l'âge-dans-un-contexte
const userInOption = option.ap(userBuilderInOption2, age);
// userInOption est Some({ name: 'Alice', age: 30 })
Ce patron est incroyablement puissant pour des choses comme la validation de formulaire, où plusieurs fonctions de validation indépendantes retournent un résultat dans un contexte (comme `Either
Monades : Séquencer des Opérations dans un Contexte
La Monade est peut-être l'abstraction fonctionnelle la plus célèbre et souvent la plus mal comprise. Une Monade est utilisée pour séquencer des opérations où chaque étape dépend du résultat de la précédente, et chaque étape retourne une valeur enveloppée dans le même contexte.
La classe de type Monade étend Applicatif et ajoute une méthode cruciale : chain
(aussi connue sous le nom de `flatMap` ou `bind`).
import { Applicative } from './Applicative';
import { Kind, URItoKind } from './hkt';
export interface Monad<M extends keyof URItoKind<any>> extends Applicative<M> {
readonly chain: <A, B>(fa: Kind<M, A>, f: (a: A) => Kind<M, B>) => Kind<M, B>;
}
La différence clé entre map
et chain
est la fonction qu'elles acceptent :
map
prend une fonction(a: A) => B
. Elle applique une fonction "normale".chain
prend une fonction(a: A) => Kind
. Elle applique une fonction qui retourne elle-mĂŞme une valeur dans le contexte monadique.
chain
est ce qui vous empêche de vous retrouver avec des contextes imbriqués comme Promise
ou Option
. Elle "aplatit" automatiquement le résultat.
Un Exemple Classique : Les Promises
Vous avez probablement utilisé des Monades sans le savoir. `Promise.prototype.then` agit comme un `chain` monadique (lorsque la fonction de rappel retourne une autre `Promise`).
interface User { id: number; name: string; }
interface Post { userId: number; content: string; }
function getUser(id: number): Promise<User> {
return Promise.resolve({ id, name: 'Bob' });
}
function getLatestPost(user: User): Promise<Post> {
return Promise.resolve({ userId: user.id, content: 'Bonjour les HKT !' });
}
// Sans `chain` (`then`), vous obtiendriez une Promise imbriquée :
const nestedPromise: Promise<Promise<Post>> = getUser(1).then(user => {
// Ce `then` agit comme `map` ici
return getLatestPost(user); // retourne une Promise, créant Promise<Promise<...>>
});
// Avec le `chain` monadique (`then` quand il aplatit), la structure est propre :
const postPromise: Promise<Post> = getUser(1).then(user => {
// `then` voit que nous avons retourné une Promise et l'aplatit automatiquement.
return getLatestPost(user);
});
Utiliser une interface Monade basée sur les HKT vous permet d'écrire des fonctions qui sont génériques sur n'importe quel calcul séquentiel et conscient du contexte, qu'il s'agisse d'opérations asynchrones (`Promise`), d'opérations qui peuvent échouer (`Either`, `Option`), ou de calculs avec un état partagé (`State`).
L'Avenir des HKT en TypeScript
Les techniques d'émulation que nous avons discutées sont puissantes mais viennent avec des compromis. Elles introduisent une quantité significative de code répétitif (boilerplate) et une courbe d'apprentissage abrupte. Les messages d'erreur du compilateur TypeScript peuvent être cryptiques quand quelque chose ne va pas avec l'encodage.
Alors, qu'en est-il du support natif ? La demande pour les Types de Kind Supérieur (ou un mécanisme pour atteindre les mêmes objectifs) est l'un des problèmes les plus anciens et les plus discutés sur le dépôt GitHub de TypeScript. L'équipe de TypeScript est consciente de la demande, mais l'implémentation des HKT présente des défis importants :
- Complexité Syntaxique : Trouver une syntaxe propre et intuitive qui s'intègre bien avec le système de types existant est difficile. Des propositions comme
type F
ouF :: * -> *
ont été discutées, mais chacune a ses avantages et ses inconvénients. - Défis d'Inférence : L'inférence de type, l'une des plus grandes forces de TypeScript, devient exponentiellement plus complexe avec les HKT. S'assurer que l'inférence fonctionne de manière fiable et performante est un obstacle majeur.
- Alignement avec JavaScript : TypeScript vise à s'aligner sur la réalité de l'exécution de JavaScript. Les HKT sont une construction purement de compilation, au niveau des types, ce qui peut créer un fossé conceptuel entre le système de types et l'exécution sous-jacente.
Bien que le support natif ne soit peut-être pas à l'horizon immédiat, la discussion continue et le succès de bibliothèques comme `fp-ts`, `Effect`, et `ts-toolbelt` prouvent que les concepts sont précieux et applicables dans un contexte TypeScript. Ces bibliothèques fournissent des encodages HKT robustes et pré-construits ainsi qu'un riche écosystème d'abstractions fonctionnelles, vous évitant d'écrire le boilerplate vous-même.
Conclusion : Un Nouveau Niveau d'Abstraction
Les Types de Kind Supérieur représentent un saut significatif dans l'abstraction au niveau des types. Ils nous permettent de passer d'une généricité sur les valeurs dans nos structures de données à une généricité sur la structure elle-même. En abstrayant des contenants comme Array
, Promise
, Option
, et Either
, nous pouvons écrire des fonctions et des interfaces universelles — comme Foncteur, Applicatif et Monade — qui capturent des patrons de calcul fondamentaux.
Bien que le manque de support natif de TypeScript nous force à nous appuyer sur des encodages complexes, les avantages peuvent être immenses pour les auteurs de bibliothèques et les développeurs d'applications travaillant sur des systèmes vastes et complexes. Comprendre les HKT vous permet de :
- Écrire du Code Plus Réutilisable : Définir une logique qui fonctionne pour n'importe quelle structure de données se conformant à une interface spécifique (par ex., `Foncteur`).
- Améliorer la Sûreté des Types : Appliquer des contrats sur la façon dont les structures de données devraient se comporter au niveau des types, prévenant ainsi des classes entières de bugs.
- Adopter les Patrons Fonctionnels : Tirer parti de patrons puissants et éprouvés du monde de la programmation fonctionnelle pour gérer les effets de bord, traiter les erreurs, et écrire du code déclaratif et composable.
Le voyage dans le monde des HKT est difficile, mais il est enrichissant, approfondit votre compréhension du système de types de TypeScript et ouvre de nouvelles possibilités pour écrire du code propre, robuste et élégant. Si vous cherchez à faire passer vos compétences en TypeScript au niveau supérieur, explorer des bibliothèques comme fp-ts
et construire vos propres abstractions simples basées sur les HKT est un excellent point de départ.