Du pattern matching en javascript ?

Le titre est un peu aguicheur : il n'existe pas vraiment de pattern matching à proprement parler en Javascript (en tout cas nativement, même si un proposal a été publié à ce sujet, mais toujours en stage 1). Vous pourrez certainement trouver des librairies qui implémente un système de pattern matching, mais avec Typescript c'est encore mieux :D Et on ne s'en prive pas chez Cartier. On utilise en l'occurence la librairie ts-pattern.

Qu'y a-t-il donc de si intéressant dans le pattern matching ? Si besoin, je vous laisse découvrir un peu plus en détails ce qu'est le pattern matching et ce qu'il apporte pour la programmation fonctionnelle. Vous pouvez même jeter un oeil à ce talk écrit comme une master class qui permet de (re)découvrir entre autre le pattern matching en Java.

Maintenant que vous avez la théorie, explorons ensemble les possibilités du pattern matching par la pratique avec des exemples concrets.

Prenons un des exemples du proposal qui permet de gérer le retour d'un fetch :

match (res) {
   when ({ status: 200, body, ...rest }): handleData(body, rest)
   when ({ status, destination: url }) if (300 <= status && status < 400):
     handleRedirect(url)
   when ({ status: 500 }) if (!this.hasRetried): do {
     retry(req);
     this.hasRetried = true;
   }
   default: throwSomething();
 }

Je vous laisse imaginer la façon dont vous auriez implémenter cet exemple "normalement" (avec un combo de switch / if)... Ci dessous une version possible :

switch(res.status) {
	case 200: {
		if (body) handleData(res) else throwSomething();
		break;
	}
	case 500: {
		if (!this.hasRetried) {
			retry(req);
			this.hasRetried = true;
			break;
		}
	}
	default: {
		if (300 <= status && status < 400) {	
			handleRedirect(url)
		} else throwSomething();
	}
}

Globalement, c'est faisable, mais on voit qu'on se prend pas mal la tête : on throw le même type d'erreur à 2 endroits différents, la lisibilité est moins bonne car on enchaîne plus souvent les if / else et surtout il y a un cas (status = 500 && this.hasRetried) qui "fallback" par le default case (ce qui n'est pas si évident que ça au 1er coup d'oeil).

Bref, vous l'aurez compris, avec ts-pattern, on peut faire (quasi) exactement la même chose que dans l'exemple du proposal et c'est bien plus lisible !

match(response)  
 .with({ status: 200 }, ({ body, ...rest }) => handleData(body, rest))  
 .with({ status: when((status) => status >= 300 && status < 400) }, ({ url }) => handleRedirect(url))
 .with({ status: when((status) => status === 500 && !this.hasRetried) }, () => {  
	retry(req);  
	this.hasRetried = true;  
 })
 .otherwise(() => throwSomething());

Ultra pratique. Et ça ne s'arrête pas là ! Avec la librairie, on a aussi la notion d'exhaustivité. Par exemple, si vous maintenez une Enum qui liste les erreurs possibles lors d'un fetch :

type ApiError =
	| "INTERNAL_ERROR",
	| "BAD_REQUEST",
	| "UNAUTHORIZED",
}

vous pouvez tout à fait appliquer ce pattern matching :

match(apiError)
  .with("INTERNAL_ERROR", () => handleInternalError())
  .with("BAD_REQUEST", () => handleBadRequest())
  .with("UNAUTHORIZED", () => handleUnauthorized())
  .exhaustive();

Le jour où la valeur du type change, vous aurez une erreur de compilation vous invitant à gérer les cas supplémentaires. Magique !

Dernier cas d'utilisation (parmi tant d'autres), vous pouvez directement faire un appel à la méthode match dans du JSX sans à avoir à cumuler des if(condition) return <Blabla /> ou des expressions ternaires nested :

// MyComponent.tsx
...
const { data, isLoading, error } = useQuery(....);
return (
	<Page>
		{match({ data, isLoading, error })
		  .with({ isLoading: true }, () => <Spinner />)
		  .with({ error: not(__.nullish) }, () => <Error />)
		  .otherwise(() => <Table dataSource={data} />) 
	</Page>
)
...

En conclusion, cette librairie a vraiment changé ma façon d'exprimer certaines problématiques. C'est vraiment très agréable à utiliser ! N'hésitez pas à partager votre avis ou vos cas d'usages si vous l'utilisez déjà !