Non malheureusement ce n'est pas (encore) possible en Javascript, ni en Typescript. Mais on peut essayer de s'en rapprocher, notamment en séparant l'identification d'un scénario de son exécution. L'objectif est de renforcer la lisibilité et rapprocher le code du problème à résoudre. Pour illustrer le propos, nous utiliserons le traitement d'un article de blog (ajout, suppression, publication, etc.) à partir des informations contenues dans cet article. De plus, je vous propose d'y aller étape par étape afin que vous puissiez appliquer ce refactoring dans votre code.
NB: Les extraits de code présentés ci-dessous sont en Typescript et reposent sur la programmation fonctionnelle. Donc pas de classes et pas d'héritage, mais des data, des fonctions et aussi des fonctions de fonctions.
Définition d'un contexte de travail
Un peu de modélisation
Commençons par définir une interface qui représente la payload à traiter, dans le cas présent un article. Cet article possède plusieurs attributs qui indique s'il est à supprimer, à publier ou à créer.
<pre><code>//article.ts
export interface Article {
delete: boolean;
publishAction: PublishAction;
id: string;
content: string;
}
/**
* Using Object instead of Enum here
* @see https://www.typescriptlang.org/docs/handbook/enums.html#objects-vs-enums
*/
export const PUBLISH_ACTION = {
none: 0,
unpublish: 1,
publish: 2,
} as const;
export type PublishAction = typeof PUBLISH_ACTION[keyof typeof PUBLISH_ACTION];</code></pre>
En complément, définissons deux interfaces pour améliorer le typage de notre exemple : un repository et un logger.
<pre><code>// dependencies.ts
export interface ArticleRepository {
createOrUpdate: <T>(entity: T) => Promise<T>;
delete: <T>(entity: T) => Promise<T>;
publish: <T>(entity: T) => Promise<T>;
unpublish: <T>(entity: T) => Promise<T>;
}
export interface Logger {
debug: (...args: any[]) => void;
info: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
}</code></pre>
Une première version (très) procédurale
Je vous propose l'implémentation suivante pour le traitement d'un article comme base de réflexion.
<pre><code>// index.ts
import { Article, PUBLISH_ACTION } from "./article";
import { ArticleRepository, Logger } from "./dependencies";
type ProcessArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const processArticle: ProcessArticle =
({ articleRepository, logger }) =>
({ article }) => {
if (article.delete) {
logger.debug(`Delete article with id : ${article.id}`);
return articleRepository
.delete(article)
.then(() =>
logger.debug(`Successfully deleted article with id : ${article.id}`)
)
.catch((err) =>
logger.warn(`Cannot delete article with id : ${article.id}`, err)
);
}
if (article.publishAction === PUBLISH_ACTION.unpublish) {
logger.debug(`Unpublish article with id : ${article.id}`);
return articleRepository
.unpublish(article)
.then(() =>
logger.debug(
`Successfully unpublished article with id : ${article.id}`
)
)
.catch((err) =>
logger.warn(`Cannot unpublish article with id : ${article.id}`, err)
);
}
if (article.publishAction === PUBLISH_ACTION.publish) {
logger.debug(`Publish article with id : ${article.id}`);
return articleRepository
.publish(article)
.then(() =>
logger.debug(`Successfully published article with id : ${article.id}`)
)
.catch((err) =>
logger.warn(`Cannot unpublish article with id : ${article.id}`, err)
);
}
if (article.content) {
logger.debug(`Create or update article with id : ${article.id}`);
return articleRepository
.createOrUpdate(article)
.then(() =>
logger.debug(
`Successfully created or updated article with id : ${article.id}`
)
)
.catch((err) =>
logger.warn(
`Cannot create or update article with id : ${article.id}`,
err
)
);
}
throw new Error(
`Unexpected value for article : ${JSON.stringify(article)}`
);
};</code></pre>
Avant de travailler sur la structure du code, parcourons ensemble le contenu de la fonction <span class="css-span">processArticle()</span> afin de comprendre comment notre article est traité.
Pour commencer, on peut déjà écarter le logger dont le rôle est d'afficher en debug les différentes étapes et en warn les anomalies sur le traitement d'un article. Ensuite on remarque que la fonction gère 5 use-case distincts : suppression, dépublication, publication, création/mise à jour et un cas d'erreur. Cependant, même si on comprend le code écrit ligne par ligne, on peut identifier plusieurs problèmes majeurs : les éléments structurants sont noyés parmi le reste du code, la fonction réalise seule plusieurs actions, l'identification des scénarios est couplée à l'exécution de ces scénarios. Cette fonction a donc deux responsabilités (l'identification du scénario et les actions à réaliser) et viole le principe de responsabilité unique (SRP).
Si vous n'êtes pas convaincu que ce couplage est problématique, essayez de visualiser les impacts sur le code des besoins suivants :
- "Bug : au moment de la publication les articles doivent avoir un contenu"
- "Feature : Rendre impossible la suppression d'articles qui sont publiés"
- "Feature : Permettre la création et la publication en une seule fois"
Sans refactoring, on voit que le code va vite devenir difficile à maintenir et il sera de plus en plus compliqué d'identifier l'intention derrière le code.
Petit détour par Clean Code
Afin de traiter certains des problèmes mentionnés précédemment, je vous propose d'utiliser une technique classique : la décomposition en plusieurs fonctions (Clean Code : "Extract till you drop").
<pre><code>import { Article, PUBLISH_ACTION } from "./article";
import { ArticleRepository, Logger } from "./dependencies";
type ProcessArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const processArticle: ProcessArticle =
({ articleRepository, logger }) =>
({ article }) => {
if (article.delete) {
return deleteArticle({ articleRepository, logger })({ article });
}
if (article.publishAction === PUBLISH_ACTION.unpublish) {
return unpublishArticle({ articleRepository, logger })({ article });
}
if (article.publishAction === PUBLISH_ACTION.publish) {
return publishArticle({ articleRepository, logger })({ article });
}
if (article.content) {
return createOrUpdateArticle({ articleRepository, logger })({ article });
}
throw new Error(
`Unexpected value for article : ${JSON.stringify(article)}`
);
};
type DeleteArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const deleteArticle: DeleteArticle =
({ articleRepository, logger }) =>
({ article }) => {
logger.debug(`Delete article with id : ${article.id}`);
return articleRepository
.delete(article)
.then(() =>
logger.debug(`Successfully deleted article with id : ${article.id}`)
)
.catch((err) =>
logger.warn(`Cannot delete article with id : ${article.id}`, err)
);
};
type UnpublishArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const unpublishArticle: UnpublishArticle =
({ articleRepository, logger }) =>
({ article }) => {
logger.debug(`Unpublish article with id : ${article.id}`);
return articleRepository
.unpublish(article)
.then(() =>
logger.debug(`Successfully unpublished article with id : ${article.id}`)
)
.catch((err) =>
logger.warn(`Cannot unpublish article with id : ${article.id}`, err)
);
};
type PublishArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const publishArticle: PublishArticle =
({ articleRepository, logger }) =>
({ article }) => {
logger.debug(`Publish article with id : ${article.id}`);
return articleRepository
.publish(article)
.then(() =>
logger.debug(`Successfully published article with id : ${article.id}`)
)
.catch((err) =>
logger.warn(`Cannot unpublish article with id : ${article.id}`, err)
);
};
type CreateOrUpdateArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const createOrUpdateArticle: CreateOrUpdateArticle =
({ articleRepository, logger }) =>
({ article }) => {
logger.debug(`Create or update article with id : ${article.id}`);
return articleRepository
.createOrUpdate(article)
.then(() =>
logger.debug(
`Successfully created or updated article with id : ${article.id}`
)
)
.catch((err) =>
logger.warn(
`Cannot create or update article with id : ${article.id}`,
err
)
);
};
</code></pre>
La première chose que l'on remarque, c'est que la lisibilité est bien meilleure. On a maintenant une base plus saine pour construire un pattern matching dans la fonction <span class="css-span">processArticle()</span>. Cela va nous permettre d'isoler l'identification du scénario afin de pouvoir ajouter facilement des nouveaux use-case ou changer les conditions sans impacter le reste du traitement.
Identifier précisément et explicitement le scénario
Explicit is better than implicit
Dans notre exemple, nous avons 5 use-case distincts, donc faisons ressortir explicitement ces 5 scénarios. Pour ce faire, on peut baser sur un simple Enum comme ci-dessous.
<pre><code>// command.ts
export const COMMAND = {
delete: 0,
unpublish: 1,
publish: 2,
createOrUpdate: 3,
unknown: 4,
} as const;
export type Command = typeof COMMAND[keyof typeof COMMAND];</code></pre>
Ensuite, nous pouvons implémenter une méthode dont le rôle est d'identifier une intention (nommée <span class="css-span">Command</span> dans notre exemple) à partir des informations contenues dans l'article. Il nous suffit de reprendre l'articulation du code précédent et de renvoyer le bon use-case.
<pre><code>// command.ts
import { Article, PUBLISH_ACTION } from "./article";
type GetCommand = (parameters: { article: Article }) => Command;
export const getCommand: GetCommand = ({ article }) => {
if (article.delete) {
return COMMAND.delete;
}
if (article.publishAction === PUBLISH_ACTION.unpublish) {
return COMMAND.unpublish;
}
if (article.publishAction === PUBLISH_ACTION.publish) {
return COMMAND.publish;
}
if (article.content) {
return COMMAND.createOrUpdate;
}
return COMMAND.unknown;
};</code></pre>
S'approcher du pattern matching
L'astuce principale est de mêler les concepts de literals et d'IIFE. On va utiliser un objet litéral comme structure de notre pattern matching. Les clés de l'objet correspondent aux différents use-case et les valeurs associées sont les implémentations de ces use-cases.
<pre><code>const objectLiteral = {
case1: () => fun1(),
case2: () => fun2(),
case3: () => fun3(),
};</code></pre>
A partir d'un objet litéral comme ci-dessus, l'objectif est de cibler le use-case et d'exécuter la bonne callback. Point important, on utilise des arrow functions pour éviter d'exécuter tous les scénarios à la création de l'objet.
<pre><code>const callback = objectLiteral["case1"];
callback(); // Will call fun1()</code></pre>
A l'étape précédente, nous avons défini une la fonction <span class="css-span">getCommand()</span> dont le rôle est d'identifier le use-case. Ensuite, nous pouvons remplacer les clés de l'objet par des valeurs de <span class="css-span">Command</span>. Et pour finir, on écrit les arrow functions à partir des fonctions existantes. Ce qui donne :
<pre><code>const objectLiteral = {
[COMMAND.delete]: () =>
deleteArticle({ articleRepository, logger })({ article }),
[COMMAND.unpublish]: () =>
unpublishArticle({ articleRepository, logger })({ article }),
[COMMAND.publish]: () =>
publishArticle({ articleRepository, logger })({ article }),
[COMMAND.createOrUpdate]: () =>
createOrUpdateArticle({ articleRepository, logger })({ article }),
[COMMAND.unknown]: () => {
throw new Error(
`Unexpected value for article : ${JSON.stringify(article)}`
);
},
};
const callback = objectLiteral[getCommand({ article })];
callback();</code></pre>
Enfin, si on reprend notre fonction initiale et que l'on retire les variables intermédiaires, on obtient la syntaxe ci-dessous qui représente l'objectif de cet article.
<pre><code>// article.ts
type ProcessArticle = (dependencies: {
articleRepository: ArticleRepository;
logger: Logger;
}) => (parameters: { article: Article }) => Promise<unknown>;
export const processArticle: ProcessArticle =
({ articleRepository, logger }) =>
({ article }) =>
({
[COMMAND.delete]: () =>
deleteArticle({ articleRepository, logger })({ article }),
[COMMAND.unpublish]: () =>
unpublishArticle({ articleRepository, logger })({ article }),
[COMMAND.publish]: () =>
publishArticle({ articleRepository, logger })({ article }),
[COMMAND.createOrUpdate]: () =>
createOrUpdateArticle({ articleRepository, logger })({ article }),
[COMMAND.unknown]: () => {
throw new Error(
`Unexpected value for article : ${JSON.stringify(article)}`
);
},
}[getCommand({ article })]());</code></pre>
Conclusion
On y est presque ! Malheureusement, on est contraint de passer par une structure intermédiaire (que j'ai appelé <span class="css-span">Command</span> dans cet exemple) afin de construire notre pattern matching.
Que pensez-vous de cette syntaxe ? Est-ce que Javascript devrait inclure un vrai pattern matching, comme en C# par exemple ?
Denis est polytechnicien (bon, de Nantes, mais quand même !) et développeur fullstack à tendance front même s’il aime bien le back, en préférant tout de même Angular mais cela ne le dérange pas de faire du Java tant que les perfs sont au rendez-vous. Bref, vous avez compris (ou pas), l’homme est polyvalent même s’il a une préférence pour le Typescript. Bah oui, c’est top de pouvoir changer de version tous les mois et toujours faire de l’ES5 en prod !
Développeur Younup rêveur, il a pour ambition de s’acheter un cybertruck et de créer des extensions de fichiers basés sur des chanteurs.