← Retour au blog
Concurrence Deux blocs de code illustrant l'anti-pattern Zalgo : une fonction releaseZalgo qui invoque son callback de façon synchrone ou asynchrone selon une condition, et le code appelant pour lequel l'ordre d'exécution de « Avant », « Done » et « Après » devient impossible à déterminer.

Callbacks : l'anti-pattern Zalgo, ne jamais mixer synchrone et asynchrone

Pourquoi un callback à la fois synchrone et asynchrone est un anti-pattern (Zalgo) : control-flow non déterministe, erreurs, tests, et la solution Effect.

📅 ✍️ Antoine Coulon
callbacksasynczalgocontrol-floweffect

S’il y a une seule chose à retenir sur la conception d’API à base de callbacks, c’est celle-ci : un callback ne doit jamais pouvoir être à la fois synchrone et asynchrone. Cette ambiguïté, en apparence anodine, fait basculer un programme déterministe dans l’incertitude la plus totale, au point qu’elle porte un nom devenu célèbre dans la communauté JavaScript : Zalgo.

Ce second volet de la série sur les callbacks part de là où s’arrêtait le précédent. Dans le premier épisode, on a vu qu’un callback n’est pas forcément asynchrone : selon l’API, il peut être invoqué immédiatement ou plus tard. La question naturelle qui en découle est alors la suivante : et si une même fonction pouvait faire les deux, tantôt synchrone, tantôt asynchrone ? C’est précisément le piège à désamorcer.

Mixer synchrone et asynchrone : un anti-pattern majeur

Pourquoi est-ce si grave de laisser un callback osciller entre les deux modes ? Parce que le synchrone et l’asynchrone n’obéissent pas aux mêmes règles. Mélanger les deux, c’est rendre incohérents quatre aspects fondamentaux d’un programme :

C’est Isaac Z. Schlueter, le créateur de npm, qui a donné un nom à cet anti-pattern dans son article Designing APIs for Asynchrony (2013). Il l’a baptisé Zalgo, du nom d’une entité maléfique et mystérieuse du folklore internet, une métaphore pour ce chaos imprévisible que l’on invoque sans le vouloir.

Anatomie de Zalgo

Considérons une fonction qui, selon une condition arbitraire, invoque son callback immédiatement ou après un délai :

// Condition booléenne arbitraire
let someCondition;

/**
 * Ici, le callback peut être soit invoqué immédiatement
 * de manière synchrone, soit de manière asynchrone en
 * fonction de la condition booléenne `someCondition`.
 *
 * C'est ici que Zalgo entre en jeu. Un mix entre
 * les deux types de callbacks, qu'on veut éviter à
 * tout prix.
 */
function releaseZalgo(callback) {
  if (someCondition) {
    callback("Done");
  } else {
    setTimeout(() => callback("Done"), 1_000);
  }
}

En fonction de someCondition, le callback est soit appelé sur-le-champ, dans la même pile d’exécution, soit reporté d’au moins une seconde via setTimeout. Du point de vue de l’appelant, on ne peut donc rien garantir sur le moment où le callback s’exécutera.

Observons maintenant le code consommateur :

console.log("Avant");

releaseZalgo(() => {
  console.log("Done");
});

console.log("Après");

Quel est l’ordre des messages dans la console ? La réponse honnête est : ça dépend. Deux scénarios sont possibles selon la branche empruntée.

Si le callback est synchrone :

Avant
Done
Après

S’il est asynchrone :

Avant
Après
Done

Un même appel, deux comportements radicalement différents. Il devient impossible de raisonner de manière déterministe sur l’ordre d’exécution du programme, à cause de l’incertitude sur la nature, synchrone ou asynchrone, du callback. Toute la logique que l’on construit par-dessus repose alors sur du sable.

La règle d’or

La parade est simple et tient en une phrase :

Si un callback peut être asynchrone, alors il doit toujours être asynchrone.

Autrement dit, on supprime l’ambiguïté en choisissant un mode unique et stable. Dès lors qu’une seule branche de votre fonction est asynchrone, forcez toutes les branches à l’être. Concrètement, dans le cas synchrone, on diffère artificiellement l’appel pour qu’il se produise systématiquement dans un tick ultérieur de l’event loop :

function releaseZalgo(callback) {
  if (someCondition) {
    // On force l'asynchronicité même dans la branche « synchrone »
    queueMicrotask(() => callback("Done"));
  } else {
    setTimeout(() => callback("Done"), 1_000);
  }
}

Avec cette correction, l’ordre est garanti : Avant, Après, puis Done, quelle que soit la condition. Le consommateur peut enfin raisonner sereinement sur son control-flow. On a apprivoisé Zalgo en lui imposant une discipline : une API doit avoir un comportement temporel prévisible et uniforme.

« Les callbacks, c’est tellement 2013 »

On pourrait objecter que ce débat appartient au passé. Il est vrai que les callbacks ne sont plus le mode dominant de gestion de l’asynchrone : les Promises, puis async/await et les générateurs ont largement pris le relais. Mais deux raisons rendent ce principe toujours d’actualité.

D’une part, une grande partie de l’écosystème JavaScript reste callback-first : d’innombrables API et modules historiques exposent encore des callbacks, et il faut savoir composer avec. D’autre part, et c’est l’essentiel, les règles ne changent pas. Une Promise qui se résout de façon imprévisible, un flux qui mélange émissions synchrones et asynchrones : Zalgo se réincarne sous d’autres formes. Le principe de prévisibilité transcende le mécanisme employé.

Le futur : quand sync et async deviennent des détails d’implémentation

Si l’on prend de la hauteur, le vrai problème n’est pas le callback en lui-même, mais le fait que la nature, synchrone ou asynchrone, d’une opération fuite jusque dans la façon dont on l’écrit et dont on raisonne sur elle. Ce que l’on aimerait, idéalement, c’est penser en termes de composition d’opérations, sans avoir à se soucier de leur nature sous-jacente. On voudrait pouvoir :

C’est précisément ce que propose Effect. Cette bibliothèque adopte une vision où l’on écrit du code synchrone et asynchrone de la même manière en TypeScript : la distinction devient un détail d’implémentation, et non plus une source d’incertitude qui contamine tout le reste du programme. Le déterminisme n’est plus une discipline que l’on s’impose à la main : il est garanti par construction.

Conclusion

Zalgo n’est pas une curiosité folklorique : c’est le rappel qu’une API se juge autant à la prévisibilité de son comportement qu’à ce qu’elle accomplit. Un callback qui hésite entre synchrone et asynchrone détruit la seule chose sur laquelle un consommateur peut s’appuyer : la certitude de l’ordre d’exécution.

La règle est intangible : si un callback peut être asynchrone, il doit toujours l’être. Et au-delà des callbacks, l’enjeu reste le même quel que soit l’outil (Promises, générateurs ou Effect) : offrir un raisonnement déterministe sur le programme. C’est cette exigence, bien plus que la technologie du moment, qui sépare une API fiable d’un piège silencieux.