Centraliser une validation métier grâce aux types
Bienvenue dans un article de la famille « Étude de cas ». Dans cet article, je vais vous présenter un bout de code qui m’a marqué aujourd’hui, que ce soit parce qu’il m’a fait réfléchir à comment nous pourrions améliorer la qualité de notre code ou parce qu’il peut me servir de contexte pour expliquer un concept ou une technique.
Notre cas : Extraction d’informations depuis un fichier docx
Le code d’aujourd’hui portera sur l’extraction et la modification d’informations depuis un fichier Word (.docx, donc une archive ZIP avec du XML à l’intérieur). Pas besoin d’en savoir plus sur le format docx pour suivre l’article.
Note : Le code a bien évidemment été tiré de son contexte. Toute notion d’asynchronie a été retirée, les fonctions strippées de leur implémentation, etc. Je ne suis même pas sûr que le code soit exécutable tel quel.
import type {
Zip, DocumentXml, ImageDimension,
HeaderRef, FooterRef, StyleId,
} from './types';
function readDocumentXml(zip: Zip): DocumentXml | null {
if (!zip.exists('word/document.xml')) return null;
return zip.readXml('word/document.xml');
}
const processImageDimensions = (
zip: Zip,
): ImageDimension[] => {
const documentXml = readDocumentXml(zip);
if (documentXml === null) return [];
return documentXml.images.map(
({ id, width, height }) => ({ id, width, height }),
);
};
const processHeaders = (zip: Zip): HeaderRef[] => {
const documentXml = readDocumentXml(zip);
if (documentXml === null) return [];
return documentXml.headerRefs;
};
const processFooters = (zip: Zip): FooterRef[] => {
const documentXml = readDocumentXml(zip);
if (documentXml === null) return [];
return documentXml.footerRefs;
};
const processStyles = (zip: Zip): StyleId[] => {
const documentXml = readDocumentXml(zip);
if (documentXml === null) return [];
return documentXml.styleIds;
};
const getInformationsFromDocument = (
document: Document,
) => {
const { zip } = document;
const images = processImageDimensions(zip);
const headers = processHeaders(zip);
const footers = processFooters(zip);
const styles = processStyles(zip);
return {
...document, images, headers, footers, styles,
};
};
J’ai envie de mettre la lumière sur deux points de ce code :
Premièrement, chaque fonction « processSomething » se retrouve à devoir gérer le cas où le document n’est pas valide : risque d’oubli, duplication de code qui amène du bruit à la lecture, et le principe de responsabilité unique qui se retrouve à pleurer dans un coin de la pièce.
Ensuite — et c’est sans doute le plus important —, la gestion des erreurs est faite sans considérer le besoin métier. Quels sont les cas pour lesquels le document pourrait ne pas être valide ? Avaler silencieusement l’erreur est-il la meilleure solution, ou ne voudrions-nous pas plutôt informer l’utilisateur ?
Pour corriger ces problèmes, je propose d’introduire trois concepts : les branded types, la surcharge de signature, et les prédicats de type.
Les branded types
Savez-vous pourquoi les types en TS sont aussi appelés « alias de type » ? Parce que c’est exactement ce qu’ils sont : un nom pour un type. Il est donc impossible de différencier deux types identiques par leur nom, ce qui peut être dommageable, car nous avons parfois très envie de différencier deux types identiques par leur nom.
Prenons comme exemple Even et Odd, deux types structurellement identiques, mais sémantiquement différents.
type Even = number;
type Odd = number;
function getEvenNumber(): Even {
return 2;
}
// Aucun problème car Odd = number et Even = number, donc Odd = Even.
const oddNumber: Odd = getEvenNumber();
En TypeScript, la solution la plus simple est de créer un nouveau type, alias du type original mais avec une étiquette unique. C’est ce qu’on appelle un « branded type ».
type Branded<T, Brand extends string> =
T & { __brand: Brand };
type Even = Branded<number, 'even'>;
type Odd = Branded<number, 'odd'>;
function getEvenNumber(): Even {
return 2 as Even;
}
// Et boom, on a enfin notre erreur de type.
const oddNumber: Odd = getEvenNumber();
Mise en pratique : acter qu’un « Zip » n’est pas un « DocxZip »
Dans le code précédent, nous passons notre temps à manipuler des « Zip » — et dans un sens c’est vrai : comme nous l’avons dit plus haut, les fichiers docx sont des zip. Mais dans un autre sens c’est faux : un « Zip » n’est pas un « DocxZip ». Les carrés et les rectangles, tout ça.
Je propose donc de créer un nouveau type « DocxZip », un « Zip » avec une petite étiquette attestant qu’il s’agit bien d’une archive contenant un document Word valide.
type DocxZip = Branded<Zip, 'docx'>;
Et voilà. Ça peut sembler un peu décevant, mais vous allez voir : c’est la base de tout ce que nous allons faire ensuite.
Les surcharges de signatures
Les surcharges de signatures sont des fonctions qui ont le même nom mais des signatures différentes. C’est une technique très puissante qui permet de surcharger la signature d’une fonction en fonction du type d’argument qu’elle reçoit.
function mul(a: Odd, b: Odd): Odd;
function mul(a: Even, b: number): Even;
function mul(a: number, b: Even): Even;
function mul(a: number, b: number): number {
return a * b;
}
C’est bien plus puissant qu’il n’y paraît, et surtout c’est très simple à ajouter dans n’importe quelle codebase : il suffit de déclarer la nouvelle signature, sans toucher à l’implémentation existante.
Mise en pratique : surcharger la signature de readDocumentXml
Jetons un œil à readDocumentXml. Son fonctionnement est assez simple : on reçoit un Zip, on le lit, et s’il s’agit bien d’un docx on renvoie un DocumentXml, sinon null.
Maintenant que nous savons différencier les « Zip qui sont peut-être des docx » des « Zip qui sont forcément des docx », nous pouvons définir une nouvelle signature pour readDocumentXml.
function readDocumentXml(zip: Zip): DocumentXml | null;
function readDocumentXml(zip: DocxZip): DocumentXml;
Nous avons maintenant une fonction plus précise sur son retour selon le type de Zip reçu, ce qui fait disparaître la gestion du null dans toutes les fonctions processSomething. Tout ça en deux lignes qui disparaissent à la compilation : je trouve que c’est vraiment pas cher payé.
Les prédicats de type
Un prédicat de type est une fonction qui, à l’exécution, se comporte comme un guard classique : elle prend une valeur, renvoie true ou false. La différence est dans la signature de retour : au lieu de : boolean, on écrit par exemple zip is DocxZip.
Ce zip is DocxZip ne produit aucun code supplémentaire. C’est une promesse faite au compilateur : si cette fonction renvoie true, alors l’argument est un DocxZip dans la branche qui suit. TypeScript appelle ça l’affinement (narrowing) du type.
function handle(lechiffre: number) {
if (!isOdd(lechiffre)) {
// lechiffre reste un number « quelconque »
throw new Error('Le nombre n\'est pas impair');
}
// ici, lechiffre est Odd — les appels en aval
// peuvent supposer un Odd exploitable
processOdd(lechiffre);
}
Sans ce prédicat de type, une validation ne suffirait pas : TypeScript ne relie pas automatiquement ce test au branded type Odd. Le prédicat fait le pont entre ce qu’on vérifie en runtime (la valeur passe bien le test) et ce qu’on veut exprimer dans le type système (on ne manipule plus un number quelconque, mais un Odd).
C’est ce maillon qui va permettre de centraliser la validation : un seul isDocxZip à la frontière, puis des process* qui acceptent un DocxZip et n’ont plus à se demander si le document existe. C’est l’idée centrale de parse, don’t validate : transformer une donnée non fiable en un type garanti, une seule fois, à la frontière.
Mise en pratique : s’assurer qu’un « Zip » est un « DocxZip » de manière centralisée
Concrètement, notre guard :
function isDocxZip(zip: Zip): zip is DocxZip {
return readDocumentXml(zip) !== null;
}
Encore une fois, toutes ces explications pour se retrouver face à trois lignes de TypeScript.
Note : il serait aussi tout à fait possible d’utiliser une assertion function pour confirmer que le
Zipest unDocxZip. Le résultat serait globalement le même, sauf que l’assertion lève une exception dans tous les cas, là où le guard laisse à l’appelant le choix de gérer l’échec. La différence se situe au niveau du sens qu’on donne à l’erreur. Est-il envisageable que ce fichier ne soit pas un docx ? Si oui, on préfère un guard. Si le cas nous semble impossible, on utilise une assertion.
Refactorisons l’exemple
Maintenant que nous avons tous les morceaux de la triforce, assemblons-les pour refactoriser le code.
import type {
Zip, DocumentXml, ImageDimension,
HeaderRef, FooterRef, StyleId,
} from './types';
type Branded<T, Brand extends string> =
T & { __brand: Brand };
type DocxZip = Branded<Zip, 'docx'>;
class InvalidDocxError extends Error {
override name = 'InvalidDocxError';
}
function readDocumentXml(
zip: Zip,
): DocumentXml | null;
function readDocumentXml(
zip: DocxZip,
): DocumentXml;
function readDocumentXml(
zip: Zip,
): DocumentXml | null {
if (!zip.exists('word/document.xml')) return null;
return zip.readXml('word/document.xml');
}
function isDocxZip(zip: Zip): zip is DocxZip {
return readDocumentXml(zip) !== null;
}
const processImageDimensions = (
zip: DocxZip,
): ImageDimension[] => {
const documentXml = readDocumentXml(zip);
return documentXml.images.map(
({ id, width, height }) => ({ id, width, height }),
);
};
const processHeaders = (
zip: DocxZip,
): HeaderRef[] => {
const documentXml = readDocumentXml(zip);
return documentXml.headerRefs;
};
const processFooters = (
zip: DocxZip,
): FooterRef[] => {
const documentXml = readDocumentXml(zip);
return documentXml.footerRefs;
};
const processStyles = (
zip: DocxZip,
): StyleId[] => {
const documentXml = readDocumentXml(zip);
return documentXml.styleIds;
};
const getInformationsFromDocument = (
document: Document,
) => {
const { zip } = document;
if (!isDocxZip(zip)) {
throw new InvalidDocxError(
'Le fichier ne contient pas de document Word'
+ ' valide.',
);
}
const images = processImageDimensions(zip);
const headers = processHeaders(zip);
const footers = processFooters(zip);
const styles = processStyles(zip);
return {
...document, images, headers, footers, styles,
};
};
Les process* ne portent plus la responsabilité de vérifier l’intégrité du document ; updateDocument concentre la décision métier. À lui de choisir s’il faut rejeter le fichier, afficher un message, logger pour le support, etc. — et l’importance de ce choix est plus explicite.
Beaucoup d’actions qui étaient effectuées de manière informelle dans le code d’origine peuvent maintenant être découpées dans leur propre fichier, avec un petit test unitaire très simple. La relecture s’en trouve simplifiée, et la réutilisation dans d’autres contextes aussi.
Pour aller plus loin (en anglais)
- Branded types — la base du
DocxZip - Type predicates —
zip is DocxZip - Function overloads — la double signature de
readDocumentXml - Assertion functions — l’alternative au guard quand l’échec est un bug, pas un cas métier recoverable
- Parse, don’t validate — un des articles qui m’a le plus influencé sur le sujet