Il suffit d’une seule fonction asynchrone, enfouie quelque part dans votre arbre d’appels, pour que la contamination remonte jusqu’au sommet. La fonction qui l’appelle doit devenir asynchrone, puis celle qui appelle cette dernière, et ainsi de suite, en cascade, jusqu’à ce que la moitié de votre base de code ait changé de signature. Ce phénomène a un nom : le Function Coloring.
Bob Nystrom a popularisé l’expression en 2015 dans un billet resté célèbre, What Color is Your Function?, mais le problème, lui, était vécu bien avant d’être nommé. C’est l’une de ces frictions que l’on finit par tenir pour acquise, jusqu’au jour où l’on réalise qu’elle n’a rien d’une fatalité.
Le principe : des fonctions qui ont une couleur
L’idée est la suivante : dans la plupart des runtimes, on peut considérer que les fonctions portent une « couleur » selon leur nature.
- 🟦 Synchrone : la fonction renvoie immédiatement son résultat.
- 🟥 Asynchrone : la fonction renvoie une promesse de résultat : une
Promise, un callback, uneFuture, selon le langage et l’API.
Cette distinction serait anecdotique si les deux couleurs cohabitaient librement. Le problème, c’est qu’elles ne le font pas. Une fonction asynchrone ne peut être gérée correctement que depuis une autre fonction asynchrone, sous peine de perdre le contrôle sur l’exécution du programme : on ne peut pas attendre un résultat asynchrone depuis un contexte synchrone sans bloquer le runtime ou abandonner la valeur de retour.
Autrement dit, appeler une fonction asynchrone depuis une fonction synchrone a un prix : la fonction appelante doit, elle aussi, devenir asynchrone.
La contamination en cascade
Prenons une fonction synchrone des plus banales, qui lit un fichier et en retourne le contenu :
function readFile(): string {
const buffer = fs.readFileSync("package.json");
return buffer.toString();
}
Tout va bien tant que l’on reste dans le monde synchrone. Mais supposons maintenant que l’on veuille passer à l’API asynchrone du système de fichiers, un choix parfaitement légitime, ne serait-ce que pour ne pas bloquer l’event loop. La signature de readFile change inévitablement :
async function readFile(): Promise<string> {
const buffer = await fs.readFile("package.json");
return buffer.toString();
}
Le contenu de la fonction n’a presque pas bougé. Pourtant, sa couleur a changé : readFile est passée de 🟦 à 🟥. Et avec elle, tous ses appelants doivent suivre. Chaque fonction qui utilisait readFile doit désormais l’await, donc devenir async, donc forcer ses propres appelants à faire de même, de proche en proche jusqu’à la racine.
Un simple changement d’implémentation (rendre une opération asynchrone) déclenche ainsi une réécriture en cascade de tout le code appelant. La décision technique reste locale, mais son coût se propage à l’ensemble de l’arbre d’appels.
Le vrai problème : une frontière architecturale permanente
Le coût de la cascade n’est que la partie visible. Ce qui pèse durablement, c’est la frontière qui se dresse de façon permanente entre les deux mondes, et la charge cognitive qu’elle impose.
Les deux couleurs n’ont pas les mêmes APIs, pas les mêmes signatures de type, pas les mêmes mécanismes de gestion d’erreurs. async/await a beaucoup fait pour réduire l’écart en rendant le code asynchrone aussi lisible que du code synchrone, mais il ne supprime pas la frontière : il la rend simplement plus confortable à franchir. La couleur reste inscrite dans la signature, et continue de dicter qui peut appeler qui.
Le principal inconvénient, à mon sens, se joue au niveau de la composition fonctionnelle. Dès que l’on cherche à assembler des fonctions entre elles, la couleur réapparaît comme une contrainte : on ne compose pas une fonction synchrone et une fonction asynchrone de la même manière, et chaque point de jonction entre les deux mondes ajoute de la friction. Ce qui devrait être un détail d’implémentation devient une préoccupation architecturale de premier plan.
Et si sync et async redevenaient un détail d’implémentation ?
C’est exactement la question que résolvent les Effect Systems. Dans ce paradigme, on ne raisonne plus en termes d’opérations synchrones ou asynchrones, mais en termes d’effets de bord (side effects). On décrit ce que le programme fait ; la manière dont l’opération est exécutée, de façon synchrone ou asynchrone, devient la responsabilité du système, et non plus la nôtre. La réconciliation des deux mondes est déléguée à un runtime dédié.
En TypeScript, Effect incarne cette approche. La frontière entre les couleurs s’efface derrière un seul datatype, Effect<A, E, R>, qui décrit un calcul à travers trois paramètres :
A: la valeur produite par l’opération en cas de succès.E: les erreurs possibles, explicitement typées et visibles dans la signature.R: les dépendances (le contexte) nécessaires à l’exécution.
L’élément clé : que l’opération soit synchrone ou asynchrone sous le capot, la composition reste rigoureusement identique. On déclare une opération de lecture de fichier sans rien dire de sa couleur :
declare function readFile(fileName: string): Effect<string, ReadFileError, FileSystem>;
Puis on la compose comme n’importe quel autre Effect, en enchaînant les étapes et en traitant les erreurs de façon déclarative :
const program = pipe(
readFile("package.json"),
Effect.catchTag("ReadFileError", () => Effect.logError("oops"))
);
Ce program 🟪 ne change pas d’une ligne selon que readFile est synchrone 🟦 ou asynchrone 🟥 dans son implémentation. C’est le runtime Effect qui décide comment exécuter le calcul : la couleur a disparu de la surface du code. Vous décrivez ce que fait votre programme, ses erreurs possibles et ses dépendances ; le runtime se charge du reste.
Le diagramme ci-dessous résume ce basculement : à gauche, le monde sans Effect, où l’ajout d’une seule fonction asynchrone force la bascule en cascade de tout l’arbre d’appels ; à droite, le monde unifié d’Effect, où la même composition vaut partout, le runtime tranchant seul entre sync et async.
Un changement de paradigme, pas une astuce de syntaxe
Il serait réducteur de voir là un simple sucre syntaxique. Les Effect Systems sont un héritage direct de la programmation fonctionnelle : on les retrouve en Haskell, ou encore avec ZIO en Scala, dont Effect s’inspire ouvertement. Leur promesse va bien au-delà de la couleur des fonctions : erreurs typées dans la signature, dépendances explicites, exécution contrôlée par le runtime.
Mais sur la question précise du Function Coloring, le verdict est sans appel : le problème ne se pose plus, non pas parce qu’on l’a contourné, mais parce qu’il n’existe plus de couleur à concilier. Sync ou async redevient ce qu’il aurait toujours dû être : un détail d’implémentation.