Comprendre les génériques TypeScript

Publié: 2022-03-10
Résumé rapide ↬ Une introduction à l'utilisation des génériques dans TypeScript avec des exemples fondés sur des cas d'utilisation réels, tels que les collections, les approches de la gestion des erreurs, le modèle de référentiel, etc. Cet article espère fournir une compréhension intuitive de la notion d'abstraction logicielle à travers les génériques.

Dans cet article, nous allons apprendre le concept de génériques dans TypeScript et examiner comment les génériques peuvent être utilisés pour écrire du code modulaire, découplé et réutilisable. En cours de route, nous discuterons brièvement de la manière dont ils s'intègrent dans de meilleurs modèles de test, des approches de la gestion des erreurs et de la séparation domaine/accès aux données.

Un exemple concret

Je veux entrer dans le monde des génériques non pas en expliquant ce qu'ils sont, mais plutôt en fournissant un exemple intuitif de leur utilité. Supposons que vous ayez été chargé de créer une liste dynamique riche en fonctionnalités. Vous pouvez l'appeler un tableau, un ArrayList , un List , un std::vector , ou quoi que ce soit, en fonction de votre expérience linguistique. Peut-être que cette structure de données doit également avoir des systèmes de tampon intégrés ou permutables (comme une option d'insertion de tampon circulaire). Ce sera un wrapper autour du tableau JavaScript normal afin que nous puissions travailler avec notre structure au lieu de tableaux simples.

Le problème immédiat que vous rencontrerez est celui des contraintes imposées par le système de type. Vous ne pouvez pas, à ce stade, accepter n'importe quel type que vous voulez dans une fonction ou une méthode d'une manière propre et agréable (nous reviendrons sur cette déclaration plus tard).

La seule solution évidente est de répliquer notre structure de données pour tous les différents types :

 const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));

La syntaxe .create() ici peut sembler arbitraire, et en effet, new SomethingList() serait plus simple, mais vous verrez pourquoi nous utilisons cette méthode de fabrique statique plus tard. En interne, la méthode create appelle le constructeur.

C'est terrible. Nous avons beaucoup de logique dans cette structure de collection, et nous la dupliquons de manière flagrante pour prendre en charge différents cas d'utilisation, brisant complètement le principe DRY dans le processus. Lorsque nous décidons de modifier notre implémentation, nous devrons propager/refléter manuellement ces modifications dans toutes les structures et tous les types que nous prenons en charge, y compris les types définis par l'utilisateur, comme dans le dernier exemple ci-dessus. Supposons que la structure de la collection elle-même ait 100 lignes de long - ce serait un cauchemar de maintenir plusieurs implémentations différentes où la seule différence entre elles est les types.

Une solution immédiate qui pourrait venir à l'esprit, surtout si vous avez un état d'esprit POO, est de considérer un "supertype" racine si vous voulez. En C #, par exemple, il se compose d'un type par le nom de object , et object est un alias pour la classe System.Object . Dans le système de types de C#, tous les types, qu'ils soient prédéfinis ou définis par l'utilisateur et qu'ils soient des types de référence ou des types de valeur, héritent directement ou indirectement de System.Object . Cela signifie que n'importe quelle valeur peut être assignée à une variable de type object (sans entrer dans la sémantique stack/heap et boxing/unboxing).

Dans ce cas, notre problème semble résolu. Nous pouvons simplement utiliser un type comme any et cela nous permettra de stocker tout ce que nous voulons dans notre collection sans avoir à dupliquer la structure, et en effet, c'est très vrai :

Plus après saut! Continuez à lire ci-dessous ↓
 const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));

Regardons l'implémentation réelle de notre liste en utilisant any :

 class AnyList { private values: any[] = []; private constructor (values: any[]) { this.values = values; // Some more construction work. } public add(value: any): void { this.values.push(value); } public where(predicate: (value: any) => boolean): AnyList { return AnyList.from(this.values.filter(predicate)); } public select(selector: (value: any) => any): AnyList { return AnyList.from(this.values.map(selector)); } public toArray(): any[] { return this.values; } public static from(values: any[]): AnyList { // Perhaps we perform some logic here. // ... return new AnyList(values); } public static create(values?: any[]): AnyList { return new AnyList(values ?? []); } // Other collection functions. // ... }

Toutes les méthodes sont relativement simples, mais nous allons commencer par le constructeur. Sa visibilité est privée, car nous supposerons que notre liste est complexe et nous souhaitons interdire toute construction arbitraire. Nous pouvons également vouloir effectuer une logique avant la construction, donc pour ces raisons, et pour garder le constructeur pur, nous déléguons ces préoccupations aux méthodes d'usine/d'assistance statiques, ce qui est considéré comme une bonne pratique.

Les méthodes statiques from et create sont fournies. La méthode from accepte un tableau de valeurs, exécute une logique personnalisée, puis les utilise pour construire la liste. La méthode create static prend un tableau facultatif de valeurs dans le cas où nous voudrions ensemencer notre liste avec des données initiales. L'« opérateur de coalescence nul » ( ?? ) est utilisé pour construire la liste avec un tableau vide dans le cas où il n'en existe pas. Si le côté gauche de l'opérande est null ou undefined , nous retomberons sur le côté droit, car dans ce cas, values ​​est facultatif et peut donc être undefined . Vous pouvez en savoir plus sur la coalescence nulle sur la page de documentation TypeScript correspondante.

J'ai également ajouté une méthode select et une méthode where . Ces méthodes enveloppent simplement la map et le filter de JavaScript respectivement. select nous permet de projeter un tableau d'éléments dans un nouveau formulaire basé sur la fonction de sélecteur fournie, et where nous permet de filtrer certains éléments en fonction de la fonction de prédicat fournie. La méthode toArray convertit simplement la liste en tableau en renvoyant la référence de tableau que nous détenons en interne.

Enfin, supposons que la classe User contienne une méthode getName qui renvoie un nom et accepte également un nom comme premier et unique argument constructeur.

Remarque : Certains lecteurs reconnaîtront Where et Select dans LINQ de C#, mais gardez à l'esprit que j'essaie de garder cela simple, donc je ne m'inquiète pas de la paresse ou de l'exécution différée. Ce sont des optimisations qui pourraient et devraient être faites dans la vraie vie.

De plus, comme note intéressante, je veux discuter de la signification de « prédicat ». En Mathématiques Discrètes et Logique Propositionnelle, nous avons le concept de « proposition ». Une proposition est une déclaration qui peut être considérée comme vraie ou fausse, telle que "quatre est divisible par deux". Un "prédicat" est une proposition qui contient une ou plusieurs variables, ainsi la véracité de la proposition dépend de celle de ces variables. Vous pouvez y penser comme une fonction, telle que P(x) = x is divisible by two , car nous avons besoin de connaître la valeur de x pour déterminer si l'énoncé est vrai ou faux. Vous pouvez en savoir plus sur la logique des prédicats ici.

Il y a quelques problèmes qui vont survenir de l'utilisation de any . Le compilateur TypeScript ne sait rien des éléments à l'intérieur de la liste/du tableau interne, il ne fournira donc aucune aide à l'intérieur de where ou select ou lors de l'ajout d'éléments :

 // Providing seed data. const userList = AnyList.create([new User('Jamie')]); // This is fine and expected. userList.add(new User('Tom')); userList.add(new User('Alice')); // This is an acceptable input to the TS Compiler, // but it's not what we want. We'll definitely // be surprised later to find strings in a list // of users. userList.add('Hello, World!'); // Also acceptable. We have a large tuple // at this point rather than a homogeneous array. userList.add(0); // This compiles just fine despite the spelling mistake (extra 's'): // The type of `users` is any. const users = userList.where(user => user.getNames() === 'Jamie'); // Property `ssn` doesn't even exist on a `user`, yet it compiles. users.toArray()[0].ssn = '000000000'; // `usersWithId` is, again, just `any`. const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Oops, it's "id" not "ID", but TS doesn't help us. // We compile just fine. console.log(usersWithId.toArray()[0].ID);

Étant donné que TypeScript sait seulement que le type de tous les éléments du tableau est any , il ne peut pas nous aider au moment de la compilation avec les propriétés inexistantes ou la fonction getNames qui n'existe même pas, donc ce code entraînera plusieurs erreurs d'exécution inattendues .

Pour être honnête, les choses commencent à être assez sombres. Nous avons essayé d'implémenter notre structure de données pour chaque type de béton que nous souhaitions supporter, mais nous nous sommes vite rendu compte que ce n'était en aucun cas maintenable. Ensuite, nous avons pensé que nous allions quelque part en utilisant any , ce qui revient à dépendre d'un supertype racine dans une chaîne d'héritage à partir de laquelle tous les types dérivent, mais nous avons conclu que nous perdions la sécurité de type avec cette méthode. Quelle est la solution, alors ?

Il s'avère qu'au début de l'article, j'ai menti (en quelque sorte) :

"Vous ne pouvez pas, à ce stade, accepter n'importe quel type que vous voulez dans une fonction ou une méthode d'une manière propre et agréable."

Vous pouvez en fait, et c'est là que les génériques entrent en jeu. Remarquez que j'ai dit "à ce stade", car je supposais que nous ne connaissions pas les génériques à ce stade de l'article.

Je commencerai par montrer l'implémentation complète de notre structure de liste avec les génériques, puis nous prendrons du recul, discuterons de ce qu'ils sont réellement et déterminerons leur syntaxe de manière plus formelle. Je l'ai nommé TypedList pour le différencier de notre précédent AnyList :

 class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }

Essayons de refaire les mêmes erreurs que précédemment :

 // Here's the magic. `TypedList` will operate on objects // of type `User` due to the `<User>` syntax. const userList = TypedList.create<User>([new User('Jamie')]); // The compiler expects this. userList.add(new User('Tom')); userList.add(new User('Alice')); // Argument of type '0' is not assignable to parameter // of type 'User'. ts(2345) userList.add(0); // Property 'getNames' does not exist on type 'User'. // Did you mean 'getName'? ts(2551) // Note: TypeScript infers the type of `users` to be // `TypedList<User>` const users = userList.where(user => user.getNames() === 'Jamie'); // Property 'ssn' does not exist on type 'User'. ts(2339) users.toArray()[0].ssn = '000000000'; // TypeScript infers `usersWithId` to be of type // `TypedList<`{ id: string, name: string }> const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Property 'ID' does not exist on type '{ id: string; name: string; }'. // Did you mean 'id'? ts(2551) console.log(usersWithId.toArray()[0].ID)

Comme vous pouvez le voir, le compilateur TypeScript nous aide activement avec la sécurité des types. Tous ces commentaires sont des erreurs que je reçois du compilateur lorsque j'essaie de compiler ce code. Les génériques nous ont permis de spécifier un type sur lequel nous souhaitons autoriser notre liste à fonctionner, et à partir de là, TypeScript peut dire les types de tout, jusqu'aux propriétés des objets individuels dans le tableau.

Les types que nous proposons peuvent être aussi simples ou complexes que nous le souhaitons. Ici, vous pouvez voir que nous pouvons passer à la fois des primitives et des interfaces complexes. Nous pourrions également passer d'autres tableaux, ou classes, ou n'importe quoi :

 const numberList = TypedList.create<number>(); numberList.add(4); const stringList = TypedList.create<string>(); stringList.add('Hello, World'); // Example of a complex type interface IAircraft { apuStatus: ApuStatus; inboardOneRPM: number; altimeter: number; tcasAlert: boolean; pushBackAndStart(): Promise<void>; ilsCaptureGlidescope(): boolean; getFuelStats(): IFuelStats; getTCASHistory(): ITCASHistory; } const aircraftList = TypedList.create<IAircraft>(); aircraftList.add(/* ... */); // Aggregate and generate report: const stats = aircraftList.select(a => ({ ...a.getFuelStats(), ...a.getTCASHistory() }));

Les utilisations particulières de T et U et <T> et <U> dans l' TypedList<T> sont des exemples de génériques en action. Après avoir rempli notre directive de construction d'une structure de collection de type sécurisé, nous laisserons cet exemple de côté pour le moment, et nous y reviendrons une fois que nous aurons compris ce que sont réellement les génériques, comment ils fonctionnent et leur syntaxe. Lorsque j'apprends un nouveau concept, j'aime toujours commencer par voir un exemple complexe du concept utilisé, de sorte que lorsque je commence à apprendre les bases, je peux faire des liens entre les sujets de base et l'exemple existant que j'ai dans mon diriger.

Que sont les génériques ?

Une manière simple de comprendre les génériques est de les considérer comme relativement analogues aux espaces réservés ou aux variables mais pour les types. Cela ne veut pas dire que vous pouvez effectuer les mêmes opérations sur un espace réservé de type générique que sur une variable, mais une variable de type générique peut être considérée comme un espace réservé qui représente un type concret qui sera utilisé à l'avenir. Autrement dit, l'utilisation de Generics est une méthode d'écriture de programmes en termes de types qui doivent être spécifiés ultérieurement. La raison pour laquelle cela est utile est qu'il nous permet de créer des structures de données réutilisables sur les différents types sur lesquels elles fonctionnent (ou indépendantes du type).

Ce n'est pas particulièrement la meilleure des explications, donc pour le dire en termes plus simples, comme nous l'avons vu, il est courant en programmation que nous ayons besoin de construire une structure fonction/classe/données qui fonctionnera sur un certain type, mais il est également courant qu'une telle structure de données doive également fonctionner sur une variété de types différents. Si nous étions coincés dans une position où nous devions déclarer statiquement le type concret sur lequel une structure de données fonctionnerait au moment où nous concevons la structure de données (au moment de la compilation), nous constaterions très rapidement que nous devons reconstruire ces structures d'une manière presque identique pour chaque type que nous souhaitons prendre en charge, comme nous l'avons vu dans les exemples ci-dessus.

Les génériques nous aident à résoudre ce problème en nous permettant de différer l'exigence d'un type concret jusqu'à ce qu'il soit réellement connu.

Génériques dans TypeScript

Nous avons maintenant une idée assez organique de l'utilité des génériques et nous en avons vu un exemple légèrement compliqué dans la pratique. Pour la plupart, l'implémentation de TypedList<T> a probablement déjà beaucoup de sens, surtout si vous venez d'un milieu linguistique typé statiquement, mais je me souviens avoir eu du mal à comprendre le concept lorsque j'ai appris pour la première fois, donc je veux construire cet exemple en commençant par des fonctions simples. Les concepts liés à l'abstraction dans les logiciels peuvent être notoirement difficiles à intérioriser, donc si la notion de génériques n'a pas encore tout à fait cliqué, c'est tout à fait correct, et j'espère qu'à la fin de cet article, l'idée sera au moins quelque peu intuitive.

Pour arriver à comprendre cet exemple, partons de fonctions simples. Nous commencerons par la "Fonction d'identité", qui est ce que la plupart des articles, y compris la documentation TypeScript elle-même, aiment utiliser.

Une « fonction d'identité », en mathématiques, est une fonction qui mappe son entrée directement à sa sortie, telle que f(x) = x . Ce que vous mettez est ce que vous sortez. Nous pouvons représenter cela, en JavaScript, comme suit :

 function identity(input) { return input; }

Ou, plus succinctement :

 const identity = input => input;

Essayer de le porter sur TypeScript ramène les mêmes problèmes de système de type que nous avons vus auparavant. Les solutions sont de taper avec any , ce qui, nous le savons, est rarement une bonne idée, de dupliquer/surcharger la fonction pour chaque type (casse DRY) ou d'utiliser Generics.

Avec cette dernière option, nous pouvons représenter la fonction comme suit :

 // ES5 Function function identity<T>(input: T): T { return input; } // Arrow Function const identity = <T>(input: T): T => input; console.log(identity<number>(5)); // 5 console.log(identity<string>('hello')); // hello

La syntaxe <T> déclare ici cette fonction comme générique. Tout comme une fonction nous permet de passer un paramètre d'entrée arbitraire dans sa liste d'arguments, avec une fonction générique, nous pouvons également passer un paramètre de type arbitraire.

La partie <T> de la signature de identity<T>(input: T): T et <T>(input: T): T dans les deux cas déclare que la fonction en question acceptera un paramètre de type générique nommé T . Tout comme les variables peuvent porter n'importe quel nom, nos espaces réservés génériques peuvent également l'être, mais c'est une convention d'utiliser une lettre majuscule « T » (« T » pour « Type ») et de descendre dans l'alphabet si nécessaire. N'oubliez pas que T est un type, nous déclarons donc également que nous accepterons un argument de fonction d' input de nom avec un type de T et que notre fonction renverra un type de T . C'est tout ce que dit la signature. Essayez de laisser T = string dans votre tête — remplacez tous les T par une string dans ces signatures. Vous voyez comment rien de tout ce qui est magique ne se passe ? Vous voyez à quel point cela ressemble à la façon non générique dont vous utilisez les fonctions au quotidien ?

Gardez à l'esprit ce que vous savez déjà sur TypeScript et les signatures de fonction. Tout ce que nous disons, c'est que T est un type arbitraire que l'utilisateur fournira lors de l'appel de la fonction, tout comme input est une valeur arbitraire que l'utilisateur fournira lors de l'appel de la fonction. Dans ce cas, input doit être quel que soit ce type T lorsque la fonction est appelée dans le futur .

Ensuite, dans le "futur", dans les deux instructions de log, nous "transmettons" le type concret que nous souhaitons utiliser, tout comme nous le faisons pour une variable. Remarquez le changement de verbiage ici — sous la forme initiale de <T> signature , lors de la déclaration de notre fonction, elle est générique — c'est-à-dire qu'elle fonctionne sur des types génériques, ou des types à spécifier plus tard. C'est parce que nous ne savons pas quel type l'appelant souhaitera utiliser lorsque nous écrivons réellement la fonction. Mais, lorsque l'appelant appelle la fonction, il sait exactement avec quel(s) type(s) il veut travailler, qui sont string et un number dans ce cas.

Vous pouvez imaginer l'idée d'avoir une fonction de journal déclarée de cette façon dans une bibliothèque tierce - l'auteur de la bibliothèque n'a aucune idée des types que les développeurs qui utilisent la bibliothèque voudront utiliser, ils rendent donc la fonction générique, reportant essentiellement le besoin pour les types de béton jusqu'à ce qu'ils soient réellement connus.

Je tiens à souligner que vous devriez penser à ce processus de la même manière que vous faites la notion de passage d'une variable à une fonction dans le but d'acquérir une compréhension plus intuitive. Tout ce que nous faisons maintenant, c'est aussi passer un type.

Au point où nous avons appelé la fonction avec le paramètre number , la signature originale, à toutes fins utiles, pourrait être considérée comme identity(input: number): number . Et, au moment où nous avons appelé la fonction avec le paramètre string , encore une fois, la signature d'origine aurait tout aussi bien pu être identity(input: string): string . Vous pouvez imaginer que, lors de l'appel, chaque T générique est remplacé par le type concret que vous fournissez à ce moment-là.

Explorer la syntaxe générique

Il existe différentes syntaxes et sémantiques pour spécifier les génériques dans le contexte des fonctions ES5, des fonctions fléchées, des alias de type, des interfaces et des classes. Nous allons explorer ces différences dans cette section.

Exploration de la syntaxe générique — Fonctions

Vous avez déjà vu quelques exemples de fonctions génériques, mais il est important de noter qu'une fonction générique peut accepter plus d'un paramètre de type générique, tout comme elle peut accepter des variables. Vous pouvez choisir de demander un, ou deux, ou trois, ou autant de types que vous voulez, tous séparés par des virgules (encore une fois, tout comme les arguments d'entrée).

Cette fonction accepte trois types d'entrée et renvoie l'un d'entre eux au hasard :

 function randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; } // Calling the function. // `value` has type `string | number | IAircraft` const value = randomValue< string, number, IAircraft >( myString, myNumber, myAircraft );

Vous pouvez également voir que la syntaxe est légèrement différente selon que nous utilisons une fonction ES5 ou une fonction de flèche, mais les deux déclarent les paramètres de type dans la signature :

 const randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; }

Gardez à l'esprit qu'il n'y a pas de "contrainte d'unicité" forcée sur les types - vous pouvez passer n'importe quelle combinaison que vous souhaitez, comme deux string s et un number , par exemple. De plus, tout comme les arguments d'entrée sont "dans la portée" du corps de la fonction, les paramètres de type générique le sont également. Le premier exemple montre que nous avons un accès complet à T , U et V depuis le corps de la fonction, et nous les avons utilisés pour déclarer un triplet local.

Vous pouvez imaginer que ces génériques fonctionnent dans un certain « contexte » ou dans une certaine « durée de vie », et cela dépend de l'endroit où ils sont déclarés. Les génériques sur les fonctions sont dans la portée de la signature et du corps de la fonction (et les fermetures créées par les fonctions imbriquées), tandis que les génériques déclarés sur une classe ou une interface ou un alias de type sont dans la portée de tous les membres de la classe ou de l'interface ou de l'alias de type.

La notion de génériques sur les fonctions ne se limite pas aux "fonctions libres" ou aux "fonctions flottantes" (fonctions non attachées à un objet ou à une classe, un terme C++), mais elles peuvent également être utilisées sur des fonctions attachées à d'autres structures.

Nous pouvons placer cette randomValue dans une classe et nous pouvons l'appeler de la même manière :

 class Utils { public randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // ... } // Or, as an arrow function: public randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // ... } }

Nous pourrions également placer une définition dans une interface :

 interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }

Ou dans un alias de type :

 type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }

Comme auparavant, ces paramètres de type génériques sont "dans la portée" de cette fonction particulière - ils ne sont pas de classe, d'interface ou d'alias de type. Ils ne vivent que dans la fonction particulière sur laquelle ils sont spécifiés. Pour partager un type générique entre tous les membres d'une structure, vous devez annoter le nom de la structure elle-même, comme nous le verrons ci-dessous.

Exploration de la syntaxe générique — Alias ​​de type

Avec les alias de type, la syntaxe générique est utilisée sur le nom de l'alias.

Par exemple, une fonction "action" qui accepte une valeur, modifie éventuellement cette valeur, mais renvoie void pourrait être écrite comme suit :

 type Action<T> = (val: T) => void;

Remarque : cela devrait être familier aux développeurs C# qui comprennent le délégué Action<T>.

Ou, une fonction de rappel qui accepte à la fois une erreur et une valeur pourrait être déclarée comme telle :

 type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }

Avec notre connaissance des génériques de fonctions, nous pourrions aller plus loin et rendre également générique la fonction sur l'objet API :

 type CallbackFunction<T> = (err: Error, data: T) => void; const api = { // `T` is available for use within this function. get<T>(uri: string, cb: CallbackFunction<T>) { /// ... } }

Maintenant, nous disons que la fonction get accepte un paramètre de type générique, et quoi qu'il en soit, CallbackFunction le reçoit. Nous avons essentiellement "passé" le T qui va dans get comme T pour CallbackFunction . Cela aurait peut-être plus de sens si nous changions les noms :

 type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }

Préfixer les paramètres de type avec T est simplement une convention, tout comme préfixer les interfaces avec I ou les variables membres avec _ . Ce que vous pouvez voir ici, c'est que CallbackFunction accepte un type ( TData ) qui représente la charge utile de données disponible pour la fonction, tandis que get accepte un paramètre de type qui représente le type/forme de données HTTP Response ( TResponse ). Le client HTTP ( api ), similaire à Axios, utilise tout ce que TResponse est comme TData pour CallbackFunction . Cela permet à l'appelant de l'API de sélectionner le type de données qu'il recevra de l'API (supposons qu'ailleurs dans le pipeline, nous ayons un middleware qui analyse le JSON dans un DTO).

Si nous voulions aller un peu plus loin, nous pourrions modifier les paramètres de type générique sur CallbackFunction pour accepter également un type d'erreur personnalisé :

 type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;

Et, tout comme vous pouvez rendre les arguments de fonction facultatifs, vous le pouvez également avec les paramètres de type. Si l'utilisateur ne fournit pas de type d'erreur, nous le définirons par défaut sur le constructeur d'erreur :

 type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;

Avec cela, nous pouvons maintenant spécifier un type de fonction de rappel de plusieurs manières :

 const apiOne = { // `Error` is used by default for `CallbackFunction`. get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }; apiOne.get<string>('uri', (err: Error, data: string) => { // ... }); const apiTwo = { // Override the default and use `HttpError` instead. get<TResponse>(uri: string, cb: CallbackFunction<TResponse, HttpError>) { // ... } }; apiTwo.get<string>('uri', (err: HttpError, data: string) => { // ... });

Cette idée de paramètres par défaut est acceptable pour toutes les fonctions, classes, interfaces, etc. — elle ne se limite pas seulement aux alias de type. Dans tous les exemples que nous avons vus jusqu'à présent, nous aurions pu affecter n'importe quel paramètre de type que nous voulions à une valeur par défaut. Les alias de type, tout comme les fonctions, peuvent prendre autant de paramètres de type générique que vous le souhaitez.

Exploration de la syntaxe générique — Interfaces

Comme vous l'avez vu, un paramètre de type générique peut être fourni à une fonction sur une interface :

 interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }

Dans ce cas, T ne vit que pour la fonction identity comme type d'entrée et de retour.

Nous pouvons également rendre un paramètre de type disponible pour tous les membres d'une interface, tout comme avec les classes et les alias de type, en spécifiant que l'interface elle-même accepte un générique. Nous parlerons du modèle de référentiel un peu plus tard lorsque nous aborderons des cas d'utilisation plus complexes pour les génériques, donc ce n'est pas grave si vous n'en avez jamais entendu parler. Le modèle de référentiel nous permet d'abstraire notre stockage de données afin de rendre la logique métier indépendante de la persistance. Si vous souhaitez créer une interface de référentiel générique fonctionnant sur des types d'entités inconnus, nous pouvons la saisir comme suit :

 interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; removeById(id: string): Promise<void>; }

Remarque : Il existe de nombreuses réflexions différentes autour des référentiels, de la définition de Martin Fowler à la définition DDD Aggregate. J'essaie simplement de montrer un cas d'utilisation pour les génériques, donc je ne suis pas trop soucieux d'être entièrement correct en termes de mise en œuvre. Il y a certainement quelque chose à dire pour ne pas utiliser de référentiels génériques, mais nous en reparlerons plus tard.

Comme vous pouvez le voir ici, IRepository est une interface qui contient des méthodes pour stocker et récupérer des données. Il fonctionne sur un paramètre de type générique nommé T , et T est utilisé comme entrée pour add et mettre à updateById , ainsi que le résultat de résolution de promesse de findById .

Gardez à l'esprit qu'il existe une très grande différence entre accepter un paramètre de type générique sur le nom de l'interface et autoriser chaque fonction elle-même à accepter un paramètre de type générique. Le premier, comme nous l'avons fait ici, garantit que chaque fonction de l'interface fonctionne sur le même type T . Autrement dit, pour un IRepository<User> , chaque méthode qui utilise T dans l'interface travaille maintenant sur des objets User . Avec cette dernière méthode, chaque fonction serait autorisée à travailler avec le type qu'elle souhaite. Il serait très particulier de ne pouvoir ajouter que des User au référentiel, mais de pouvoir recevoir des Policies ou des Orders , par exemple, ce qui est la situation potentielle dans laquelle nous nous retrouverions si nous ne pouvions pas imposer que le type est uniforme dans toutes les méthodes.

Une interface donnée peut contenir non seulement un type partagé, mais également des types propres à ses membres. Par exemple, si nous voulions imiter un tableau, nous pourrions taper une interface comme celle-ci :

 interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }

Dans ce cas, forEach et map ont accès à T à partir du nom de l'interface. Comme indiqué, vous pouvez imaginer que T est dans la portée de tous les membres de l'interface. Malgré cela, rien n'empêche les fonctions individuelles d'accepter également leurs propres paramètres de type. La fonction map le fait, avec U . Maintenant, map a accès à la fois à T et U . Nous avons dû nommer le paramètre avec une lettre différente, comme U , car T est déjà pris et nous ne voulons pas de collision de noms. Tout comme son nom, map "mappera" les éléments de type T dans le tableau vers de nouveaux éléments de type U . Il mappe T s à U s. La valeur de retour de cette fonction est l'interface elle-même, qui fonctionne maintenant sur le nouveau type U , de sorte que nous pouvons quelque peu imiter la syntaxe chaînée fluide de JavaScript pour les tableaux.

Nous verrons bientôt un exemple de la puissance des génériques et des interfaces lorsque nous implémenterons le modèle de référentiel et discuterons de l'injection de dépendance. Encore une fois, on peut accepter autant de paramètres génériques que sélectionner un ou plusieurs paramètres par défaut empilés à la fin d'une interface.

Exploration de la syntaxe générique — Classes

Tout comme nous pouvons passer un paramètre de type générique à un alias de type, une fonction ou une interface, nous pouvons également en passer un ou plusieurs à une classe. Ce faisant, ce paramètre de type sera accessible à tous les membres de cette classe ainsi qu'aux classes de base étendues ou aux interfaces implémentées.

Construisons une autre classe de collection, mais un peu plus simple que TypedList ci-dessus, afin que nous puissions voir l'interopérabilité entre les types génériques, les interfaces et les membres. Nous verrons un exemple de passage d'un type à une classe de base et d'héritage d'interface un peu plus tard.

Notre collection prendra simplement en charge les fonctions CRUD de base en plus d'une map et de la méthode forEach .

 class Collection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): Collection<U> { return new Collection<U>(this.elements.map(func)); } } const stringCollection = new Collection<string>(); stringCollection.add('Hello, World!'); const numberCollection = new Collection<number>(); numberCollection.add(3.14159); const aircraftCollection = new Collection<IAircraft>(); aircraftCollection.add(myAircraft);

Discutons de ce qui se passe ici. La classe Collection accepte un paramètre de type générique nommé T . Ce type devient accessible à tous les membres de la classe. Nous l'utilisons pour définir un tableau privé de type T[] , que nous aurions également pu noter sous la forme Array<T> (Voir ? Génériques encore pour le typage normal des tableaux TS). De plus, la plupart des fonctions membres utilisent ce T d'une manière ou d'une autre, par exemple en contrôlant les types qui sont ajoutés et supprimés ou en vérifiant si la collection contient un élément.

Enfin, comme nous l'avons vu précédemment, la méthode map nécessite son propre paramètre de type générique. We need to define in the signature of map that some type T is mapped to some type U through a callback function, thus we need a U . That U is unique to that function in particular, which means we could have another function in that class that also accepts some type named U , and that'd be fine, because those types are only “in scope” for their functions and not shared across them, thus there are no naming collisions. What we can't do is have another function that accepts a generic parameter named T , for that'd conflict with the T from the class signature.

You can see that when we call the constructor, we pass in the type we want to work with (that is, what type each element of the internal array will be). In the calling code at the bottom of the example, we work with string s, number s, and IAircraft s.

How could we make this work with an interface? What if we have different collection interfaces that we might want to swap out or inject into calling code? To get that level of reduced coupling (low coupling and high cohesion is what we should always aim for), we'll need to depend on an abstraction. Generally, that abstraction will be an interface, but it could also be an abstract class.

Our collection interface will need to be generic, so let's define it:

 interface ICollection<T> { add(t: T): void; contains(t: T): boolean; remove(t: T): void; forEach(func: (elem: T, index: number) => void): void; map<U>(func: (elem: T, index: number) => U): ICollection<U>; }

Now, let's suppose we have different kinds of collections. We could have an in-memory collection, one that stores data on disk, one that uses a database, and so on. By having an interface, the dependent code can depend upon the abstraction, permitting us to swap out different implementations without affecting the existing code. Here is the in-memory collection.

 class InMemoryCollection<T> implements ICollection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): ICollection<U> { return new InMemoryCollection<U>(this.elements.map(func)); } }

The interface describes the public-facing methods and properties that our class is required to implement, expecting you to pass in a concrete type that those methods will operate upon. However, at the time of defining the class, we still don't know what type the API caller will wish to use. Thus, we make the class generic too — that is, InMemoryCollection expects to receive some generic type T , and whatever it is, it's immediately passed to the interface, and the interface methods are implemented using that type.

Calling code can now depend on the interface:

 // Using type annotation to be explicit for the purposes of the // tutorial. const userCollection: ICollection<User> = new InMemoryCollection<User>(); function manageUsers(userCollection: ICollection<User>) { userCollection.add(new User()); }

With this, any kind of collection can be passed into the manageUsers function as long as it satisfies the interface. This is useful for testing scenarios — rather than dealing with over-the-top mocking libraries, in unit and integration test scenarios, I can replace my SqlServerCollection<T> (for example) with InMemoryCollection<T> instead and perform state-based assertions instead of interaction-based assertions. This setup makes my tests agnostic to implementation details, which means they are, in turn, less likely to break when refactoring the SUT.

At this point, we should have worked up to the point where we can understand that first TypedList<T> example. Here it is again:

 class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }

The class itself accepts a generic type parameter named T , and all members of the class are provided access to it. The instance method select and the two static methods from and create , which are factories, accept their own generic type parameter named U .

The create static method permits the construction of a list with optional seed data. It accepts some type named U to be the type of every element in the list as well as an optional array of U elements, typed as U[] . When it calls the list's constructor with new , it passes that type U as the generic parameter to TypedList . This creates a new list where the type of every element is U . It is exactly the same as how we could call the constructor of our collection class earlier with new Collection<SomeType>() . The only difference is that the generic type is now passing through the create method rather than being provided and used at the top-level.

I want to make sure this is really, really clear. I've stated a few times that we can think about passing around types in a similar way that we do variables. It should already be quite intuitive that we can pass a variable through as many layers of indirection as we please. Forget generics and types for a moment and think about an example of the form:

 class MyClass { private constructor (t: number) {} public static create(u: number) { return new MyClass(u); } } const myClass = MyClass.create(2.17);

This is very similar to what is happening with the more-involved example, the difference being that we're working on generic type parameters, not variables. Here, 2.17 becomes the u in create , which ultimately becomes the t in the private constructor.

In the case of generics:

 class MyClass<T> { private constructor () {} public static create<U>() { return new MyClass<U>(); } } const myClass = MyClass.create<number>();

The U passed to create is ultimately passed in as the T for MyClass<T> . When calling create , we provided number as U , thus now U = number . We put that U (which, again, is just number ) into the T for MyClass<T> , so that MyClass<T> effectively becomes MyClass<number> . The benefit of generics is that we're opened up to be able to work with types in this abstract and high-level fashion, similar to how we can normal variables.

The from method constructs a new list that operates on an array of elements of type U . It uses that type U , just like create , to construct a new instance of the TypedList class, now passing in that type U for T .

The where instance method performs a filtering operation based upon a predicate function. There's no mapping happening, thus the types of all elements remain the same throughout. The filter method available on JavaScript's array returns a new array of values, which we pass into the from method. So, to be clear, after we filter out the values that don't satisfy the predicate function, we get an array back containing the elements that do. All those elements are still of type T , which is the original type that the caller passed to create when the list was first created. Those filtered elements get given to the from method, which in turn creates a new list containing all those values, still using that original type T . The reason why we return a new instance of the TypedList class is to be able to chain new method calls onto the return result. This adds an element of “immutability” to our list.

Hopefully, this all provides you with a more intuitive example of generics in practice, and their reason for existence. Next, we'll look at a few of the more advanced topics.

Generic Type Inference

Throughout this article, in all cases where we've used generics, we've explicitly defined the type we're operating on. It's important to note that in most cases, we do not have to explicitly define the type parameter we pass in, for TypeScript can infer the type based on usage.

If I have some function that returns a random number, and I pass the return result of that function to identity from earlier without specifying the type parameter, it will be inferred automatically as number :

// `value` is inferred as type `number`. const value = identity(getRandomNumber());

Pour démontrer l'inférence de type, j'ai précédemment supprimé toutes les annotations de type techniquement superflues de notre structure TypedList , et vous pouvez voir, à partir des images ci-dessous, que TSC déduit toujours tous les types correctement :

TypedList sans déclarations de type superflues :

 class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T) { this.values.push(value); } public where(predicate: (value: T) => boolean) { return TypedList.from(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U) { return TypedList.from(this.values.map(selector)); } public toArray() { return this.values; } public static from<U>(values: U[]) { // Perhaps we perform some logic here. // ... return new TypedList(values); } public static create<U>(values?: U[]) { return new TypedList(values ?? []); } // Other collection functions. // .. }

Sur la base des valeurs de retour de la fonction et des types d'entrée transmis à from et au constructeur, TSC comprend toutes les informations de type. Sur l'image ci-dessous, j'ai assemblé plusieurs images qui montrent l'extension de langage Code TypeScript de Visual Studio (et donc le compilateur) en déduisant tous les types :

inférence de type ts
( Grand aperçu )

Contraintes génériques

Parfois, nous voulons mettre une contrainte autour d'un type générique. Nous ne pouvons peut-être pas prendre en charge tous les types existants, mais nous pouvons en prendre en charge un sous-ensemble. Disons que nous voulons construire une fonction qui renvoie la longueur d'une collection. Comme vu ci-dessus, nous pourrions avoir de nombreux types différents de tableaux/collections, du Array JavaScript par défaut à nos propres. Comment faire savoir à notre fonction qu'un type générique a une propriété de length qui lui est attachée ? De même, comment restreindre les types concrets que nous passons dans la fonction à ceux qui contiennent les données dont nous avons besoin ? Un exemple comme celui-ci, par exemple, ne fonctionnerait pas :

 function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }

La réponse est d'utiliser des contraintes génériques. Nous pouvons définir une interface qui décrit les propriétés dont nous avons besoin :

 interface IHasLength { length: number; }

Maintenant, lors de la définition de notre fonction générique, nous pouvons contraindre le type générique à être celui qui étend cette interface :

 function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }

Exemples concrets

Dans les deux prochaines sections, nous aborderons quelques exemples concrets de génériques qui créent un code plus élégant et facile à raisonner. Nous avons vu beaucoup d'exemples triviaux, mais je souhaite discuter de certaines approches de la gestion des erreurs, des modèles d'accès aux données et de l'état/des accessoires React frontaux.

Exemples concrets - Approches de la gestion des erreurs

JavaScript contient un mécanisme de première classe pour gérer les erreurs, comme le font la plupart des langages de programmation — try / catch . Malgré cela, je ne suis pas très fan de son apparence lorsqu'il est utilisé. Cela ne veut pas dire que je n'utilise pas le mécanisme, je le fais, mais j'ai tendance à essayer de le cacher autant que possible. En faisant abstraction de try / catch away, je peux également réutiliser la logique de gestion des erreurs dans les opérations susceptibles d'échouer.

Supposons que nous construisions une couche d'accès aux données. Il s'agit d'une couche de l'application qui enveloppe la logique de persistance pour traiter la méthode de stockage des données. Si nous effectuons des opérations de base de données et si cette base de données est utilisée sur un réseau, des erreurs spécifiques à la base de données et des exceptions transitoires sont susceptibles de se produire. Une partie de la raison d'avoir une couche d'accès aux données dédiée est d'abstraire la base de données de la logique métier. Pour cette raison, nous ne pouvons pas avoir de telles erreurs spécifiques à la base de données qui sont lancées dans la pile et hors de cette couche. Nous devons d'abord les envelopper.

Regardons une implémentation typique qui utiliserait try / catch :

 async function queryUser(userID: string): Promise<User> { try { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }

Le basculement sur true n'est qu'une méthode pour pouvoir utiliser les instructions switch case pour ma logique de vérification des erreurs au lieu d'avoir à déclarer une chaîne de if/else if - une astuce que j'ai entendue pour la première fois de @Jeffijoe.

Si nous avons plusieurs fonctions de ce type, nous devons reproduire cette logique de traitement des erreurs, ce qui est une très mauvaise pratique. Cela semble assez bon pour une fonction, mais ce sera un cauchemar avec beaucoup. Pour résumer cette logique, nous pouvons l'envelopper dans une fonction de gestion des erreurs personnalisée qui passera par le résultat, mais interceptera et enveloppera toutes les erreurs si elles sont générées :

 async function withErrorHandling<T>( dalOperation: () => Promise<T> ): Promise<T> { try { // This unwraps the promise and returns the type `T`. return await dalOperation(); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }

Pour s'assurer que cela a du sens, nous avons une fonction intitulée withErrorHandling qui accepte un paramètre de type générique T . Ce T représente le type de la valeur de résolution réussie de la promesse que nous attendons renvoyée par la fonction de rappel dalOperation . Habituellement, puisque nous renvoyons juste le résultat de retour de la fonction async dalOperation , nous n'aurions pas besoin de l' await car cela encapsulerait la fonction dans une seconde promesse superflue, et nous pourrions laisser l' await au code appelant. Dans ce cas, nous devons attraper toutes les erreurs, donc await est nécessaire.

Nous pouvons maintenant utiliser cette fonction pour encapsuler nos opérations DAL antérieures :

 async function queryUser(userID: string) { return withErrorHandling<User>(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); }); }

Et voilà. Nous avons une fonction de requête utilisateur de type sûr et sans erreur.

De plus, comme vous l'avez vu précédemment, si le compilateur TypeScript dispose de suffisamment d'informations pour déduire implicitement les types, vous n'avez pas à les transmettre explicitement. Dans ce cas, TSC sait que le résultat de retour de la fonction correspond au type générique. Ainsi, si mapper.toDomain(user) un type de User , vous n'auriez pas du tout besoin de transmettre le type :

 async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }

Une autre approche de la gestion des erreurs que j'ai tendance à aimer est celle des types monadiques. Le Monade Soit est un type de données algébrique de la forme Either<T, U> , où T peut représenter un type d'erreur et U peut représenter un type d'échec. L'utilisation des types monadiques écoute la programmation fonctionnelle, et un avantage majeur est que les erreurs deviennent sécurisées pour le type - une signature de fonction normale ne dit rien à l'appelant de l'API sur les erreurs que cette fonction pourrait générer. Supposons que nous renvoyions une erreur NotFound depuis l'intérieur de queryUser . Une signature de queryUser(userID: string): Promise<User> ne nous dit rien à ce sujet. Mais, une signature comme queryUser(userID: string): Promise<Either<NotFound, User>> le fait absolument. Je n'expliquerai pas comment fonctionnent les monades comme l'une ou l'autre des monades dans cet article car elles peuvent être assez complexes, et il existe une variété de méthodes qu'elles doivent avoir pour être considérées comme monadiques, telles que le mappage/la liaison. Si vous souhaitez en savoir plus à leur sujet, je vous recommande deux des conférences NDC de Scott Wlaschin, ici et ici, ainsi que la conférence de Daniel Chamber ici. Ce site ainsi que ces articles de blog peuvent également être utiles.

Exemples concrets — Modèle de référentiel

Examinons un autre cas d'utilisation où les génériques pourraient être utiles. La plupart des systèmes back-end doivent s'interfacer avec une base de données d'une manière ou d'une autre - cela peut être une base de données relationnelle comme PostgreSQL, une base de données de documents comme MongoDB, ou peut-être même une base de données de graphes, comme Neo4j.

Étant donné qu'en tant que développeurs, nous devrions viser des conceptions faiblement couplées et hautement cohérentes, il serait juste d'examiner quelles pourraient être les ramifications de la migration des systèmes de bases de données. Il serait également juste de considérer que différents besoins d'accès aux données pourraient préférer différentes approches d'accès aux données (cela commence à entrer un peu dans CQRS, qui est un modèle pour séparer les lectures et les écritures. Voir le post de Martin Fowler et la liste MSDN si vous le souhaitez Les livres "Implementing Domain Driven Design" de Vaughn Vernon et "Patterns, Principles, and Practices of Domain-Driven Design" de Scott Millet sont également de bonnes lectures). Nous devrions également envisager des tests automatisés. La majorité des tutoriels qui expliquent la construction de systèmes back-end avec Node.js mêlent code d'accès aux données avec logique métier avec routage. Autrement dit, ils ont tendance à utiliser MongoDB avec l'ODM Mongoose, en adoptant une approche Active Record et en n'ayant pas une séparation nette des préoccupations. De telles techniques sont mal vues dans les grandes applications ; au moment où vous décidez de migrer un système de base de données vers un autre, ou au moment où vous réalisez que vous préféreriez une approche différente de l'accès aux données, vous devez supprimer cet ancien code d'accès aux données, le remplacer par un nouveau code, et j'espère que vous n'avez pas introduit de bogues dans le routage et la logique métier en cours de route.

Bien sûr, vous pourriez affirmer que les tests unitaires et d'intégration empêcheront les régressions, mais si ces tests se retrouvent couplés et dépendants des détails de mise en œuvre auxquels ils devraient être agnostiques, ils risquent également de se casser dans le processus.

Une approche courante pour résoudre ce problème est le modèle de référentiel. Il dit que pour appeler le code, nous devrions permettre à notre couche d'accès aux données d'imiter une simple collection en mémoire d'objets ou d'entités de domaine. De cette façon, nous pouvons laisser l'entreprise piloter la conception plutôt que la base de données (modèle de données). Pour les grandes applications, un modèle architectural appelé Domain-Driven Design devient utile. Les référentiels, dans le modèle de référentiel, sont des composants, le plus souvent des classes, qui encapsulent et conservent en interne toute la logique d'accès aux sources de données. Avec cela, nous pouvons centraliser le code d'accès aux données sur une seule couche, ce qui le rend facilement testable et facilement réutilisable. De plus, nous pouvons placer une couche de mappage entre les deux, ce qui nous permet de mapper des modèles de domaine indépendants de la base de données à une série de mappages de table un à un. Chaque fonction disponible sur le référentiel peut éventuellement utiliser une méthode d'accès aux données différente si vous le souhaitez.

Il existe de nombreuses approches et sémantiques différentes pour les référentiels, les unités de travail, les transactions de base de données entre les tables, etc. Puisqu'il s'agit d'un article sur les génériques, je ne veux pas trop entrer dans les détails, je vais donc illustrer un exemple simple ici, mais il est important de noter que différentes applications ont des besoins différents. Un référentiel pour les agrégats DDD serait assez différent de ce que nous faisons ici, par exemple. La façon dont je décris les implémentations du référentiel ici n'est pas la façon dont je les implémente dans des projets réels, car il y a beaucoup de fonctionnalités manquantes et des pratiques architecturales moins que souhaitées en cours d'utilisation.

Supposons que nous ayons des Users et des Tasks comme modèles de domaine. Ceux-ci pourraient simplement être des POTO - des objets TypeScript simples. Il n'y a aucune notion de base de données intégrée à eux, ainsi, vous n'appelleriez pas User.save() , par exemple, comme vous le feriez avec Mongoose. En utilisant le modèle de référentiel, nous pouvons conserver un utilisateur ou supprimer une tâche de notre logique métier comme suit :

 // Querying the DB for a User by their ID. const user: User = await userRepository.findById(userID); // Deleting a Task by its ID. await taskRepository.deleteById(taskID); // Deleting a Task by its owner's ID. await taskRepository.deleteByUserId(userID);

De toute évidence, vous pouvez voir comment toute la logique d'accès aux données désordonnée et transitoire est cachée derrière cette façade/abstraction de référentiel, rendant la logique métier indépendante des problèmes de persistance.

Commençons par créer quelques modèles de domaine simples. Ce sont les modèles avec lesquels le code de l'application va interagir. Ils sont anémiques ici mais auraient leur propre logique pour satisfaire les invariants commerciaux dans le monde réel, c'est-à-dire qu'ils ne seraient pas de simples sacs de données.

 interface IHasIdentity { id: string; } class User implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _username: string ) {} public get id() { return this._id; } public get username() { return this._username; } } class Task implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _title: string ) {} public get id() { return this._id; } public get title() { return this._title; } }

Vous verrez dans un instant pourquoi nous extrayons les informations de typage d'identité vers une interface. Cette méthode de définition des modèles de domaine et de tout passer par le constructeur n'est pas la façon dont je le ferais dans le monde réel. De plus, s'appuyer sur une classe de modèle de domaine abstrait aurait été plus préférable que l'interface pour obtenir gratuitement l'implémentation de l' id .

Pour le référentiel, puisque, dans ce cas, nous nous attendons à ce que bon nombre des mêmes mécanismes de persistance soient partagés entre différents modèles de domaine, nous pouvons résumer nos méthodes de référentiel à une interface générique :

 interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; deleteById(id: string): Promise<void>; existsById(id: string): Promise<boolean>; }

Nous pourrions aller plus loin et créer également un référentiel générique pour réduire la duplication. Par souci de brièveté, je ne le ferai pas ici, et je dois noter que les interfaces de référentiel générique telles que celle-ci et les référentiels génériques, en général, ont tendance à être mal vues, car vous pourriez avoir certaines entités qui sont en lecture seule, ou en écriture -only, ou qui ne peut pas être supprimé, ou similaire. Cela dépend de l'application. De plus, nous n'avons pas de notion d '«unité de travail» pour partager une transaction entre les tables, une fonctionnalité que j'implémenterais dans le monde réel, mais, encore une fois, puisqu'il s'agit d'une petite démo, je ne vouloir devenir trop technique.

Commençons par implémenter notre UserRepository . Je vais définir une interface IUserRepository qui contient des méthodes spécifiques aux utilisateurs, permettant ainsi au code appelant de dépendre de cette abstraction lorsque nous injectons la dépendance dans les implémentations concrètes :

 interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository implements IUserRepository { // There are 6 methods to implement here all using the // concrete type of `User` - Five from IRepository<User> // and the one above. }

Le référentiel de tâches serait similaire mais contiendrait des méthodes différentes selon l'application.

Ici, nous définissons une interface qui étend une interface générique, nous devons donc passer le type concret sur lequel nous travaillons. Comme vous pouvez le voir sur les deux interfaces, nous avons l'idée que nous envoyons ces modèles de domaine POTO et que nous les sortons. Le code appelant n'a aucune idée du mécanisme de persistance sous-jacent, et c'est là le but.

La prochaine considération à prendre en compte est que, selon la méthode d'accès aux données que nous choisissons, nous devrons gérer les erreurs spécifiques à la base de données. Nous pourrions placer Mongoose ou le générateur de requêtes Knex derrière ce référentiel, par exemple, et dans ce cas, nous devrons gérer ces erreurs spécifiques - nous ne voulons pas qu'elles remontent à la logique métier car cela romprait la séparation des préoccupations et introduire un plus grand degré de couplage.

Définissons un référentiel de base pour les méthodes d'accès aux données que nous souhaitons utiliser et qui peut gérer les erreurs pour nous :

 class BaseKnexRepository { // A constructor. /** * Wraps a likely to fail database operation within a function that handles errors by catching * them and wrapping them in a domain-safe error. * * @param dalOp The operation to perform upon the database. */ public async withErrorHandling<T>(dalOp: () => Promise<T>) { try { return await dalOp(); } catch (e) { // Use a proper logger: console.error(e); // Handle errors properly here. } } }

Maintenant, nous pouvons étendre cette classe de base dans le référentiel et accéder à cette méthode générique :

 interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository extends BaseKnexRepository implements IUserRepository { private readonly dbContext: Knex | Knex.Transaction; public constructor (private knexInstance: Knex | Knex.Transaction) { super(); this.dbContext = knexInstance; } // Example `findById` implementation: public async findById(id: string): Promise<User> { return this.withErrorHandling<User>(async () => { const dbUser = await this.dbContext<DbUser>() .select() .where({ user_id: id }) .first(); // Maps type DbUser to User return mapper.toDomain(dbUser); }); } // There are 5 methods to implement here all using the // concrete type of `User`. }

Notez que notre fonction récupère un DbUser de la base de données et le mappe à un modèle de domaine User avant de le renvoyer. C'est le modèle Data Mapper et il est crucial de maintenir la séparation des préoccupations. DbUser est un mappage un à un à la table de la base de données - c'est le modèle de données sur lequel le référentiel fonctionne - et dépend donc fortement de la technologie de stockage de données utilisée. Pour cette raison, les DbUser ne quitteront jamais le Repository et seront mappés sur un modèle de domaine User avant d'être renvoyés. Je n'ai pas montré l'implémentation de DbUser , mais il pourrait s'agir simplement d'une simple classe ou d'une interface.

Jusqu'à présent, en utilisant le Repository Pattern, optimisé par Generics, nous avons réussi à résumer les problèmes d'accès aux données en petites unités, ainsi qu'à maintenir la sécurité de type et la réutilisabilité.

Enfin, pour les besoins des tests unitaires et d'intégration, disons que nous conserverons une implémentation de référentiel en mémoire afin que, dans un environnement de test, nous puissions injecter ce référentiel et effectuer des assertions basées sur l'état sur le disque plutôt que de nous moquer d'un cadre moqueur. Cette méthode oblige tout à s'appuyer sur les interfaces accessibles au public plutôt que de permettre aux tests d'être couplés aux détails d'implémentation. Étant donné que les seules différences entre chaque référentiel sont les méthodes qu'ils choisissent d'ajouter sous l'interface ISomethingRepository , nous pouvons créer un référentiel générique en mémoire et l'étendre dans des implémentations spécifiques à un type :

 class InMemoryRepository<T extends IHasIdentity> implements IRepository<T> { protected entities: T[] = []; public findById(id: string): Promise<T> { const entityOrNone = this.entities.find(entity => entity.id === id); return entityOrNone ? Promise.resolve(entityOrNone) : Promise.reject(new NotFound()); } // Implement the rest of the IRepository<T> methods here. }

Le but de cette classe de base est d'exécuter toute la logique de gestion du stockage en mémoire afin que nous n'ayons pas à le dupliquer dans les référentiels de test en mémoire. En raison de méthodes telles que findById , ce référentiel doit comprendre que les entités contiennent un champ id , c'est pourquoi la contrainte générique sur l'interface IHasIdentity est nécessaire. Nous avons déjà vu cette interface - c'est ce que nos modèles de domaine ont implémenté.

Avec cela, lorsqu'il s'agit de créer le référentiel d'utilisateurs ou de tâches en mémoire, nous pouvons simplement étendre cette classe et obtenir la plupart des méthodes implémentées automatiquement :

 class InMemoryUserRepository extends InMemoryRepository<User> { public async existsByUsername(username: string): Promise<boolean> { const userOrNone = this.entities.find(entity => entity.username === username); return Boolean(userOrNone); // or, return !!userOrNone; } // And that's it here. InMemoryRepository implements the rest. }

Ici, notre InMemoryRepository doit savoir que les entités ont des champs tels que id et username , nous transmettons donc User comme paramètre générique. User implémente déjà IHasIdentity , donc la contrainte générique est satisfaite, et nous indiquons également que nous avons également une propriété username .

Désormais, lorsque l'on souhaite utiliser ces référentiels de la Business Logic Layer, c'est assez simple :

 class UserService { public constructor ( private readonly userRepository: IUserRepository, private readonly emailService: IEmailService ) {} public async createUser(dto: ICreateUserDTO) { // Validate the DTO: // ... // Create a User Domain Model from the DTO const user = userFactory(dto); // Persist the Entity await this.userRepository.add(user); // Send a welcome email await this.emailService.sendWelcomeEmail(user); } }

(Notez que dans une application réelle, nous déplacerions probablement l'appel à emailService vers une file d'attente de tâches afin de ne pas ajouter de latence à la demande et dans l'espoir de pouvoir effectuer des tentatives idempotentes en cas d'échec (- pas que l'envoi d'e-mails soit particulièrement idempotent en premier lieu). De plus, le fait de transmettre l'intégralité de l'objet utilisateur au service est également discutable. L'autre problème à noter est que nous pourrions nous retrouver ici dans une position où le serveur plante après la persistance de l'utilisateur mais avant que l'e-mail ne soit Il existe des modèles d'atténuation pour empêcher cela, mais pour des raisons de pragmatisme, une intervention humaine avec une journalisation appropriée fonctionnera probablement très bien).

Et voilà, en utilisant le modèle de référentiel avec la puissance des génériques, nous avons complètement découplé notre DAL de notre BLL et avons réussi à nous interfacer avec notre référentiel de manière sécurisée. Nous avons également développé un moyen de construire rapidement des référentiels en mémoire de même type sécurisés à des fins de tests unitaires et d'intégration, permettant de véritables tests de boîte noire et indépendants de l'implémentation. Rien de tout cela n'aurait été possible sans les types génériques.

En tant que clause de non-responsabilité, je tiens à souligner une fois de plus que cette implémentation du référentiel manque beaucoup. Je voulais que l'exemple reste simple car l'accent est mis sur l'utilisation des génériques, c'est pourquoi je n'ai pas géré la duplication ni les transactions. Des implémentations de référentiel décentes nécessiteraient un article à elles seules pour expliquer pleinement et correctement, et les détails de l'implémentation changent selon que vous utilisez une architecture N-Tier ou DDD. Cela signifie que si vous souhaitez utiliser le modèle de référentiel, vous ne devez pas considérer mon implémentation ici comme une pratique exemplaire.

Exemples concrets - État de réaction et accessoires

L'état, la référence et le reste des crochets pour les composants fonctionnels React sont également génériques. Si j'ai une interface contenant des propriétés pour les Task et que je souhaite en conserver une collection dans un composant React, je pourrais le faire comme suit :

 import React, { useState } from 'react'; export const MyComponent: React.FC = () => { // An empty array of tasks as the initial state: const [tasks, setTasks] = useState<Task[]>([]); // A counter: // Notice, type of `number` is inferred automatically. const [counter, setCounter] = useState(0); return ( <div> <h3>Counter Value: {counter}</h3> <ul> { tasks.map(task => ( <li key={task.id}> <TaskItem {...task} /> </li> )) } </ul> </div> ); };

De plus, si nous voulons passer une série d'accessoires dans notre fonction, nous pouvons utiliser le type générique React.FC<T> et avoir accès aux props :

 import React from 'react'; interface IProps { id: string; title: string; description: string; } export const TaskItem: React.FC<IProps> = (props) => { return ( <div> <h3>{props.title}</h3> <p>{props.description}</p> </div> ); };

Le type d' props est déduit automatiquement comme IProps par le compilateur TS.

Conclusion

Dans cet article, nous avons vu de nombreux exemples différents de génériques et de leurs cas d'utilisation, des collections simples aux approches de gestion des erreurs, en passant par l'isolement de la couche d'accès aux données, etc. Dans les termes les plus simples, les génériques nous permettent de construire des structures de données sans avoir besoin de connaître le temps concret sur lequel elles fonctionneront au moment de la compilation. Espérons que cela aide à ouvrir un peu plus le sujet, à rendre la notion de génériques un peu plus intuitive et à faire ressortir leur véritable pouvoir.