Catégories
Astuces et Design

Comprendre les génériques TypeScript – Smashing Magazine

A propos de l'auteur

Jamie est un développeur de logiciels de 18 ans situé au Texas. Il a un intérêt particulier pour l'architecture d'entreprise (DDD / CQRS / ES), l'écriture élégante et testable…
Plus à propos
Jamie

Une introduction à l'utilisation des génériques dans TypeScript avec des exemples ancrés dans des cas d'utilisation réels, tels que des collections, des approches de 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 des 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 expliquerons brièvement comment ils s'intègrent dans de meilleurs modèles de test, des approches de gestion des erreurs et une séparation domaine / accès aux données.

Un exemple du monde réel

Je veux entrer dans le monde des génériques ne pas en expliquant ce qu'ils sont, mais plutôt en donnant 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, une List, une std::vector, ou quoi que ce soit, selon votre langue. Peut-être que cette structure de données doit également avoir des systèmes de tampons intégrés ou échangeables (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 types. Vous ne pouvez pas, à ce stade, accepter le type de votre choix dans une fonction ou une méthode de manière claire et nette (nous reviendrons sur cette déclaration plus tard).

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

const intList = IntegerList.create();
intList.add(4);

const stringList = StringList.create();
stringList.add('hello');

const userList = UserList.create();
userList.add(new User('Jamie'));

le .create() la syntaxe ici peut sembler arbitraire, et en fait, new SomethingList() serait plus simple, mais vous verrez pourquoi nous utilisons cette méthode d'usine statique plus tard. En interne, le 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 collection elle-même ait une longueur de 100 lignes – ce serait un cauchemar de maintenir plusieurs implémentations différentes où la seule différence entre elles réside dans les types.

Une solution immédiate qui pourrait vous venir à l'esprit, surtout si vous avez un état d'esprit POO, est d'envisager un «super-type» racine si vous voulez. En C #, par exemple, il s'agit d'un type du nom de object, et object est un alias pour le System.Object classe. 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’il s’agisse de types de référence ou de types de valeur, héritent directement ou indirectement de System.Object. Cela signifie que toute valeur peut être affecté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:

const intList = AnyList.create();
intList.add(4);

const stringList = AnyList.create();
stringList.add('hello');

const userList = AnyList.create();
userList.add(new User('Jamie'));

Examinons la mise en œuvre 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 la construction arbitraire. Nous pouvons également vouloir exécuter la logique avant la construction, donc pour ces raisons, et pour garder le constructeur pur, nous déléguons ces préoccupations à des méthodes d'usine / d'assistance statiques, ce qui est considéré comme une bonne pratique.

Les méthodes statiques from et create sont prévus. La méthode from accepte un tableau de valeurs, exécute une logique personnalisée, puis les utilise pour construire la liste. le create La méthode statique prend un tableau facultatif de valeurs pour dans le cas où nous voulons amorcer notre liste avec les données initiales. L '«opérateur de fusion nul» (??) est utilisé pour construire la liste avec un tableau vide dans le cas où celui-ci n'est pas fourni. Si le côté gauche de l'opérande est null ou undefined, nous retomberons du bon côté, car dans ce cas, values est facultative, et peut donc être undefined. Vous pouvez en savoir plus sur la fusion nullish sur la page de documentation TypeScript appropriée.

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

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

Remarque: Certains lecteurs reconnaîtront Where et Select du LINQ de C #, mais gardez à l'esprit que j'essaie de garder cela simple, donc je ne suis pas inquiet 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 découler de l'utilisation de any. Le compilateur TypeScript ne sait rien des éléments à l'intérieur de la liste / 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);

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

Pour être honnête, les choses commencent à paraître assez lamentables. Nous avons essayé d'implémenter notre structure de données pour chaque type de béton que nous souhaitions prendre en charge, mais nous nous sommes rapidement 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 de racine dans une chaîne d'héritage dont tous les types dérivent, mais nous avons conclu que nous perdons 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 le type de votre choix dans une fonction ou une méthode d'une manière propre et agréable."

Vous pouvez réellement, 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 la mise en œuvre 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 se différencier de notre ancien AnyList:

class TypedList {
    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 {
        return TypedList.from(this.values.filter(predicate));
    }

    public select(selector: (value: T) => U): TypedList {
        return TypedList.from(this.values.map(selector));
    }

    public toArray(): T() {
        return this.values;
    }

    public static from(values: U()): TypedList {
        // Perhaps we perform some logic here.
        // ...
    
        return new TypedList(values);
    }

    public static create(values?: U()): TypedList {
        return new TypedList(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 `` syntax.
const userList = TypedList.create((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`
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é de type. Tous ces commentaires sont des erreurs que je reçois du compilateur lors de la tentative de compilation de ce code. Les génériques nous ont permis de spécifier un type sur lequel nous souhaitons permettre à notre liste de 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 quoi que ce soit:

const numberList = TypedList.create();
numberList.add(4);

const stringList = TypedList.create();
stringList.add('Hello, World');

// Example of a complex type
interface IAircraft {
    apuStatus: ApuStatus;
    inboardOneRPM: number;
    altimeter: number;
    tcasAlert: boolean;

    pushBackAndStart(): Promise;
    ilsCaptureGlidescope(): boolean;
    getFuelStats(): IFuelStats;
    getTCASHistory(): ITCASHistory;
}

const aircraftList = TypedList.create();
aircraftList.add(/* ... */);

// Aggregate and generate report:
const stats = aircraftList.select(a => ({
    ...a.getFuelStats(),
    ...a.getTCASHistory()
}));

Les utilisations particulières de T et U et et dans le TypedList la mise en œuvre 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 derrière nous pour l'instant, 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 puisse faire des liens entre les sujets de base et l'exemple existant que j'ai dans mon tête.

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 parce que cela nous permet de créer des structures de données réutilisables dans les différents types sur lesquels elles opèrent (ou indépendamment 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 de 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 bloqué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 trouverions très rapidement que nous devons reconstruire ceux-ci structures presque exactement de la même manière 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 de béton jusqu'à ce qu'il soit réellement connu.

Génériques dans TypeScript

Nous avons maintenant une idée assez naturelle des raisons pour lesquelles les génériques sont utiles et nous en avons vu un exemple un peu compliqué dans la pratique. Pour la plupart, le TypedList la mise en œuvre a probablement déjà beaucoup de sens, surtout si vous venez d'un contexte linguistique à typage statique, 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 fonctions simples. Les concepts liés à l'abstraction dans les logiciels peuvent être notoirement difficiles à internaliser, donc si la notion de génériques n'a pas encore tout à fait cliqué, c'est tout à fait bien, et j'espère qu'à la fin de cet article, l'idée sera au moins quelque peu intuitive.

Pour commencer à comprendre cet exemple, travaillons à partir de fonctions simples. Nous commencerons par la "Fonction d'identité", 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 sur sa sortie, telle que f(x) = x. Ce que vous mettez est ce que vous sortez. Nous pouvons représenter cela, en JavaScript, comme:

function identity(input) {
    return input;
}

Ou, plus brièvement:

const identity = input => input;

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

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

// ES5 Function
function identity(input: T): T {
    return input;
}

// Arrow Function
const identity = (input: T): T => input;

console.log(identity(5));       // 5
console.log(identity('hello')); // hello

le La syntaxe ici déclare 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.

le partie de la signature de identity(input: T): T et (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 avoir n'importe quel nom, nos espaces réservés génériques peuvent l'être aussi, mais c'est une convention d'utiliser une lettre majuscule «T» («T» pour «Type») et de descendre dans l'alphabet si nécessaire. Rappelles toi, T est un type, nous déclarons donc également que nous accepterons un argument de fonction de nom input avec un type de T et que notre fonction retournera un type de T. C’est tout ce que dit la signature. Essayez de laisser T = string dans votre tête – remplacez tous les Ts avec string dans ces signatures. Vous voyez comment il ne se passe rien de magique? Voyez à quel point il est similaire à la façon non générique dont vous utilisez les fonctions tous les jours?

Gardez à l'esprit ce que vous savez déjà sur TypeScript et les signatures de fonctions. 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 n'importe quel type T est lorsque la fonction est appelée dans le futur.

Ensuite, dans le «futur», dans les deux instructions log, nous «passons» le type concret que nous souhaitons utiliser, tout comme nous faisons une variable. Remarquez le changement de verbiage ici – sous la forme initiale de 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 ultérieurement. C’est parce que nous ne savons pas quel type l’appelant souhaite utiliser lorsque nous écrivons réellement la fonction. Mais, lorsque l'appelant appelle la fonction, il sait exactement avec quel (s) type (s) il souhaite travailler, lesquels sont string et 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 lib voudront utiliser, donc ils rendent la fonction générique, essentiellement reporter le besoin de types de béton jusqu'à ce qu'ils soient réellement connus.

Je veux souligner que tu devrait pensez à ce processus de la même manière que vous faites la notion de passer 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 number paramètre, la signature originale, à toutes fins utiles, pourrait être considérée comme identity(input: number): number. Et, au point où nous avons appelé la fonction avec le string paramètre, encore une fois, la signature originale aurait tout aussi bien pu être identity(input: string): string. Vous pouvez imaginer que, lors de l'appel, chaque générique T est remplacé par le type de béton que vous fournissez à ce moment-là.

Explorer la syntaxe générique

Il existe différentes syntaxes et sémantiques pour spécifier des 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 plusieurs paramètres de type générique, tout comme des variables. Vous pouvez choisir d'en demander un, 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 de manière aléatoire:

function randomValue(
    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 flèche, mais les deux déclarent les paramètres de type dans la signature:

const randomValue = (
    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'aucune «contrainte d'unicité» n'est imposée aux types – vous pouvez transmettre n'importe quelle combinaison que vous souhaitez, par exemple deux strings et a 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 à partir du corps de la fonction, et nous les avons utilisés pour déclarer un 3-tuple local.

Vous pouvez imaginer que ces génériques fonctionnent dans un certain «contexte» ou pendant 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.

On peut placer ça randomValue dans une classe et nous pouvons l'appeler de la même manière:

class Utils {
    public randomValue(
        one: T, 
        two: U, 
        three: V
    ): T | U | V {
        // ...
    }

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

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

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

Ou dans un alias de type:

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

Tout comme avant, ces paramètres de type générique sont «dans la portée» de cette fonction particulière – ils ne sont ni de classe, ni d'interface, ni de type à l'échelle de l'alias. Ils ne vivent que dans le cadre de 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, peut éventuellement muter cette valeur, mais qui renvoie void pourrait être écrite comme suit:

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

Remarque: Cela doit être familier aux développeurs C # qui comprennent l'action déléguer.

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

type CallbackFunction = (err: Error, data: T) => void;

const usersApi = {
    get(uri: string, cb: CallbackFunction) {
        /// ...
    }
}

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

type CallbackFunction = (err: Error, data: T) => void;

const api = {
    // `T` is available for use within this function.
    get(uri: string, cb: CallbackFunction) {
        /// ...
    }
}

Maintenant, nous disons que le get function accepte un paramètre de type générique, et quoi que ce soit, CallbackFunction le reçoit. Nous avons essentiellement «passé» le T qui entre dans get comme le T pour CallbackFunction. Cela aurait peut-être plus de sens si nous changeons les noms:

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

const api = {
    get(uri: string, cb: CallbackFunction) {
        // ...
    }
}

Préfixer les paramètres de type avec T est simplement une convention, tout comme le préfixe des interfaces avec I ou des variables membres avec _. Ce que vous pouvez voir ici, c'est que CallbackFunction accepte un certain 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 de réponse HTTP (TResponse). Le client HTTP (api), similaire à Axios, utilise tout ce qui TResponse est comme le TData pour CallbackFunction. Cela permet à l'appelant d'API de sélectionner le type de données qu'il recevra de l'API (supposons qu'à un autre endroit du pipeline, nous ayons un middleware qui analyse le JSON en 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 = (err: TError, data: TData) => void;

Et, tout comme vous pouvez rendre les arguments de fonction facultatifs, vous pouvez également utiliser des 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 = (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(uri: string, cb: CallbackFunction) {
        // ...
    }
};

apiOne.get('uri', (err: Error, data: string) => {
    // ...
});

const apiTwo = {
    // Override the default and use `HttpError` instead.
    get(uri: string, cb: CallbackFunction) {
        // ...
    }
};

apiTwo.get('uri', (err: HttpError, data: string) => {
    // ...
});

Cette idée de paramètres par défaut est acceptable dans toutes les fonctions, classes, interfaces, etc. – elle ne se limite pas aux alias de type. Dans tous les exemples que nous avons vus jusqu'à présent, nous aurions pu attribuer n'importe quel paramètre de type à 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;
}

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

Nous pouvons également rendre un paramètre de type disponible à 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 Repository Pattern 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 pourrions la taper comme suit:

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

Remarque: Il existe de nombreuses idées différentes autour des référentiels, de la définition de Martin Fowler à la définition de DDD Aggregate. J'essaie simplement de montrer un cas d'utilisation des génériques, donc je ne suis pas trop soucieux d'être totalement correct en ce qui concerne la 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 updateById, ainsi que le résultat de la résolution des promesses findById.

Gardez à l'esprit qu'il existe une très grande différence entre l'acceptation d'un paramètre de type générique sur le nom de l'interface et le fait d'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, chaque méthode qui utilise T dans l'interface travaille maintenant sur User objets. Avec cette dernière méthode, chaque fonction serait autorisée à fonctionner avec le type qu'elle souhaite. Ce serait très étrange de ne pouvoir ajouter que Users au référentiel mais être en mesure de recevoir Policies ou Orders retour, par exemple, qui est la situation potentielle dans laquelle nous nous trouverions si nous ne pouvions pas imposer que le type soit uniforme dans toutes les méthodes.

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

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

Dans ce cas, les deux forEach et map avoir 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. le map fonction fait, avec U. Maintenant, map a accès aux deux T et U. Nous avons dû nommer le paramètre une lettre différente, comme U, parce que 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 cartographie Ts à Us. La valeur de retour de cette fonction est l'interface elle-même, fonctionnant désormais sur le nouveau type U, afin que nous puissions imiter quelque peu la syntaxe chaînable 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épendances. Encore une fois, on peut accepter autant de paramètres génériques ainsi 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 transmettre 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.

Créons 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 transmission d'un type à une classe de base et à l'héritage d'interface un peu plus tard.

Notre collection ne prendra en charge que les fonctions CRUD de base en plus d'un map et forEach méthode.

class Collection {
    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(func: (elem: T, index: number) => U): Collection {
        return new Collection(this.elements.map(func));
    }
}

const stringCollection = new Collection();
stringCollection.add('Hello, World!');

const numberCollection = new Collection();
numberCollection.add(3.14159);

const aircraftCollection = new Collection();
aircraftCollection.add(myAircraft);

Discutons de ce qui se passe ici. le Collection la classe 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 pu également désigner sous la forme Array (Voir à nouveau? Génériques pour le typage normal des tableaux TS). De plus, la plupart des fonctions membres utilisent T d'une certaine manière, 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, le map La méthode nécessite son propre paramètre de type générique. Nous devons définir dans la signature de map qu'un type T est mappé à un type U via une fonction de rappel, nous avons donc besoin d'un U. Cette U est unique à cette fonction en particulier, ce qui signifie que nous pourrions avoir une autre fonction dans cette classe qui accepte également un type nommé U, et ce serait très bien, car ces types sont uniquement «dans la portée» de leurs fonctions et ne sont pas partagés entre elles, il n'y a donc pas de collision de noms. Ce que nous ne pouvons pas faire, c'est avoir une autre fonction qui accepte un paramètre générique nommé T, car cela serait en conflit avec le T à partir de la signature de la classe.

Vous pouvez voir que lorsque nous appelons le constructeur, nous passons le type avec lequel nous voulons travailler (c'est-à-dire quel type sera chaque élément du tableau interne). Dans le code d'appel en bas de l'exemple, nous travaillons avec strings, numberle sable IAircrafts.

Comment pourrions-nous faire fonctionner cela avec une interface? Et si nous avons différentes interfaces de collecte que nous pourrions vouloir échanger ou injecter dans le code d'appel? Pour obtenir ce niveau de couplage réduit (un couplage faible et une cohésion élevée sont ce que nous devrions toujours viser), nous devrons dépendre d’une abstraction. En général, cette abstraction sera une interface, mais elle pourrait aussi être une classe abstraite.

Notre interface de collecte devra être générique, définissons-la:

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

Maintenant, supposons que nous ayons différents types de collections. Nous pourrions avoir une collection en mémoire, une qui stocke des données sur le disque, une qui utilise une base de données, et ainsi de suite. En ayant une interface, le code dépendant peut dépendre de l'abstraction, ce qui nous permet d'échanger différentes implémentations sans affecter le code existant. Voici la collection en mémoire.

class InMemoryCollection implements ICollection {
    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(func: (elem: T, index: number) => U): ICollection {
        return new InMemoryCollection(this.elements.map(func));
    }
}

L'interface décrit les méthodes et les propriétés publiques que notre classe doit implémenter, en attendant que vous passiez un type concret sur lequel ces méthodes fonctionneront. Cependant, au moment de définir la classe, nous ne savons toujours pas quel type l'appelant de l'API souhaite utiliser. Ainsi, nous rendons la classe générique aussi – c'est-à-dire InMemoryCollection s'attend à recevoir un type générique T, et quoi qu’il en soit, il est immédiatement passé à l’interface, et les méthodes d’interface sont implémentées à l’aide de ce type.

Le code d'appel peut désormais dépendre de l'interface:

// Using type annotation to be explicit for the purposes of the
// tutorial.
const userCollection: ICollection = new InMemoryCollection();

function manageUsers(userCollection: ICollection) {
    userCollection.add(new User());
}

Avec cela, tout type de collection peut être transmis manageUsers fonctionne tant qu'il satisfait l'interface. Ceci est utile pour tester des scénarios – plutôt que de traiter avec des bibliothèques de simulation exagérées, dans des scénarios de test unitaires et d'intégration, je peux remplacer mon SqlServerCollection (par exemple) avec InMemoryCollection à la place et effectuer assertions basées sur l'état au lieu d'assertions basées sur l'interaction. Cette configuration rend mes tests indépendants des détails de mise en œuvre, ce qui signifie qu'ils sont, à leur tour, moins susceptibles de se casser lors de la refactorisation du SUT.

À ce stade, nous aurions dû travailler jusqu'au point où nous pouvons comprendre cela en premier TypedList exemple. Le voici encore:

class TypedList {
    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 {
        return TypedList.from(this.values.filter(predicate));
    }

    public select(selector: (value: T) => U): TypedList {
        return TypedList.from(this.values.map(selector));
    }

    public toArray(): T() {
        return this.values;
    }

    public static from(values: U()): TypedList {
        // Perhaps we perform some logic here.
        // ...
    
        return new TypedList(values);
    }

    public static create(values?: U()): TypedList {
        return new TypedList(values ?? ());
    }

    // Other collection functions.
    // ..
}

La classe elle-même accepte un paramètre de type générique nommé T, et tous les membres de la classe y ont accès. La méthode d'instance select et les deux méthodes statiques from et create, qui sont des usines, acceptent leur propre paramètre de type générique nommé U.

le create La méthode statique permet la construction d'une liste avec des données de départ facultatives. Il accepte un type nommé U être le type de chaque élément de la liste ainsi qu'un tableau facultatif de U éléments, saisis comme U(). Lorsqu'il appelle le constructeur de la liste avec new, ça passe ce type U comme paramètre générique de TypedList. Cela crée une nouvelle liste où le type de chaque élément est U. C'est exactement la même manière que nous pourrions appeler le constructeur de notre classe de collection plus tôt avec new Collection(). La seule différence est que le type générique passe maintenant par le create méthode plutôt que d'être fournie et utilisée au plus haut niveau.

Je veux m'assurer que c'est vraiment, vraiment clair. J'ai dit à quelques reprises que nous pouvions penser à transmettre des types de la même manière que nous faisons des variables. Il devrait déjà être assez intuitif de pouvoir passer une variable à travers autant de couches d'indirection que nous le souhaitons. Oubliez les génériques et les types pendant un moment et pensez à un exemple du formulaire:

class MyClass {
    private constructor (t: number) {}
    
    public static create(u: number) {
        return new MyClass(u);
    }
}

const myClass = MyClass.create(2.17);

Ceci est très similaire à ce qui se passe avec l'exemple le plus complexe, la différence étant que nous travaillons sur des paramètres de type générique, pas sur des variables. Ici, 2.17 devient le u dans create, qui devient finalement le t dans le constructeur privé.

Dans le cas des génériques:

class MyClass {
    private constructor () {}
    
    public static create() {
        return new MyClass();
    }
}

const myClass = MyClass.create();

le U transmis à create est finalement transmis comme le T pour MyClass. Lors de l'appel create, nous avons fourni number comme U, donc maintenant U = number. Nous mettons cela U (qui, encore une fois, est juste number) dans le T pour MyClass, pour que MyClass devient effectivement MyClass. L'avantage des génériques est que nous sommes ouverts pour pouvoir travailler avec des types de cette manière abstraite et de haut niveau, de la même manière que nous pouvons normaliser les variables.

le from méthode construit une nouvelle liste qui opère sur un tableau d'éléments de type U. Il utilise ce type U, juste comme create, pour construire une nouvelle instance du TypedList classe, passant maintenant dans ce type U pour T.

le where La méthode instance effectue une opération de filtrage basée sur une fonction de prédicat. Il n’y a pas de mappage, donc les types de tous les éléments restent les mêmes partout. le filter La méthode disponible sur le tableau de JavaScript renvoie un nouveau tableau de valeurs, que nous transmettons au from méthode. Donc, pour être clair, après avoir filtré les valeurs qui ne satisfont pas la fonction de prédicat, nous récupérons un tableau contenant les éléments qui le font. Tous ces éléments sont toujours de type T, qui est le type d'origine que l'appelant a passé create lorsque la liste a été créée pour la première fois. Ces éléments filtrés sont donnés au from méthode, qui à son tour crée une nouvelle liste contenant toutes ces valeurs, toujours en utilisant ce type d'origine T. La raison pour laquelle nous renvoyons une nouvelle instance du TypedList class est de pouvoir enchaîner les nouveaux appels de méthode sur le résultat renvoyé. Cela ajoute un élément d '«immuabilité» à notre liste.

Espérons que tout cela vous fournira un exemple plus intuitif des génériques dans la pratique et leur raison d'être. Ensuite, nous examinerons quelques-uns des sujets les plus avancés.

Inférence de type générique

Tout au long de cet article, dans tous les cas où nous avons utilisé des génériques, nous avons explicitement défini le type sur lequel nous opérons. Il est important de noter que dans la plupart des cas, nous n'avons pas à définir explicitement le paramètre de type que nous transmettons, car TypeScript peut déduire le type en fonction de l'utilisation.

Si j'ai une fonction qui renvoie un nombre aléatoire et que je passe le résultat de cette fonction à identity plus tôt sans spécifier le paramètre de type, il sera déduit automatiquement comme number:

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

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

TypedList sans déclarations de type étrangères:

class TypedList {
    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(selector: (value: T) => U) {
        return TypedList.from(this.values.map(selector));
    }

    public toArray() {
        return this.values;
    }

    public static from(values: U()) {
        // Perhaps we perform some logic here.
        // ...
    
        return new TypedList(values);
    }

    public static create(values?: U()) {
        return new TypedList(values ?? ());
    }

    // Other collection functions.
    // ..
}

Basé sur les valeurs de retour de fonction et basé sur les types d'entrée passés dans from et le 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) 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 créer une fonction qui renvoie la longueur d’une collection. Comme vu ci-dessus, nous pourrions avoir de nombreux types différents de tableaux / collections, à partir du JavaScript par défaut Array à nos personnalisés. Comment faire savoir à notre fonction qu'un type générique a un length propriété qui y est attachée? De même, comment restreindre les types concrets que nous transmettons à la fonction à ceux qui contiennent les données dont nous avons besoin? Un exemple comme celui-ci, par exemple, ne fonctionnerait pas:

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

Exemples du monde réel

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

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 je peux. En faisant abstraction try/catch loin, 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 encapsule la logique de persistance pour gérer la méthode de stockage des données. Si nous effectuons des opérations sur la 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 de soustraire la base de données à la logique métier. Pour cette raison, nous ne pouvons pas avoir de telles erreurs spécifiques à la base de données rejetées dans la pile et hors de cette couche. Nous devons d'abord les envelopper.

Examinons une mise en œuvre typique qui utiliserait try/catch:

async function queryUser(userID: string): Promise {
    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());
        }
    }
}

Commutation true est simplement une méthode pour pouvoir utiliser le commutateur case instructions pour ma logique de vérification des erreurs au lieu d'avoir à déclarer une chaîne de if / else if – une astuce dont j'ai entendu parler pour la première fois @Jeffijoe.

Si nous avons plusieurs fonctions de ce type, nous devons répliquer cette logique d'encapsulation des erreurs, ce qui est une très mauvaise pratique. Cela semble assez bon pour une fonction, mais ce sera un cauchemar avec plusieurs. 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 intercepter et encapsuler toutes les erreurs si elles sont levées:

async function withErrorHandling(
    dalOperation: () => Promise
): Promise {
    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 garantir que cela a du sens, nous avons une fonction intitulée withErrorHandling qui accepte un paramètre de type générique T. Cette T représente le type de la valeur de résolution réussie de la promesse que nous attendons du retour du dalOperation fonction de rappel. Habituellement, puisque nous ne faisons que renvoyer le résultat de retour de l'async dalOperation fonction, nous n’aurions pas besoin de await cela pour cela envelopperait la fonction dans une deuxième promesse superflue, et nous pourrions laisser le awaitau code d'appel. Dans ce cas, nous devons détecter toutes les erreurs, donc await est requis.

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

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

Et nous y voilà. Nous avons une fonction de requête utilisateur de fonction de sécurité de type et de sécurité d'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 renvoyé par la fonction correspond au type générique. Ainsi, si mapper.toDomain(user) a renvoyé un type de User, vous n’avez 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. L'Either Monad est un type de données algébrique du formulaire Either, où T peut représenter un type d'erreur, et U peut représenter un type d'échec. L’utilisation des types monadiques permet d’écouter la programmation fonctionnelle, et l’un des principaux avantages est que les erreurs deviennent sécurisées. Une signature de fonction normale ne dit rien à l’appelant de l’API sur les erreurs que cette fonction peut générer. Supposons que nous jetions un NotFound erreur de l'intérieur queryUser. Une signature de queryUser(userID: string): Promise ne nous dit rien à ce sujet. Mais, une signature comme queryUser(userID: string): Promise> fait absolument. Je n’expliquerai pas comment les monades comme la Monade Either fonctionnent dans cet article, car elles peuvent être assez complexes, et il existe une variété de méthodes dont elles doivent ê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 du monde réel – Modèle de référentiel

Jetons un coup d'œil à un autre cas d'utilisation où les génériques pourraient être utiles. La plupart des systèmes dorsaux doivent s'interfacer avec une base de données d'une manière ou d'une autre – cela pourrait ê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, telle que Neo4j.

Puisque, en tant que développeurs, nous devrions viser des conceptions faiblement couplées et hautement cohésives, ce serait un argument juste de considérer 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 peuvent 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 l'article de Martin Fowler et la liste MSDN si vous le souhaitez pour en savoir plus. Les livres «Implementation 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 didacticiels qui expliquent la création de systèmes dorsaux avec Node.js mêlent le code d'accès aux données à la logique métier et au routage. Autrement dit, ils ont tendance à utiliser MongoDB avec l'ODM Mongoose, en adoptant une approche d'enregistrement actif et en ne séparant pas clairement les préoccupations. De telles techniques sont mal vues dans les grandes applications; au moment où vous décidez que vous souhaitez migrer un système de base de données vers un autre, ou au moment où vous réalisez que vous préférez une approche différente de l'accès aux données, vous devez déchirer l'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 trouvent couplés et dépendants des détails d'implémentation auxquels ils devraient être indépendants, ils risquent également d'interrompre le processus.

Une approche courante pour résoudre ce problème est le modèle de référentiel. Il dit qu'en appelant du 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 manière, 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 pour accéder aux sources de données. Avec cela, nous pouvons centraliser le code d'accès aux données sur une 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. Comme il s’agit d’un article sur les génériques, je ne veux pas trop entrer dans les mauvaises herbes. 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 DDD Aggregates serait très différent de ce que nous faisons ici, par exemple. La manière dont je décris les implémentations du référentiel ici n'est pas la manière dont je les implémente dans de vrais projets, 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 Users et Tasks comme modèles de domaine. Il peut s'agir simplement de POTO – des objets TypeScript simples. Il n’existe aucune notion de base de données intégrée, vous n’appeleriez donc pas User.save(), par exemple, comme vous le feriez avec Mongoose. À l'aide du 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 du 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 d'application interagira. 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 saisie d'identité dans une interface. Cette méthode de définition de 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 le id mise en œuvre gratuitement.

Pour le référentiel, étant donné que, dans ce cas, nous nous attendons à ce que plusieurs 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 {
    add(entity: T): Promise;
    findById(id: string): Promise;
    updateById(id: string, updated: T): Promise;
    deleteById(id: string): Promise;
    existsById(id: string): Promise;
}

Nous pourrions aller plus loin et créer également un référentiel générique pour réduire les doublons. Par souci de concision, 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 pouvez avoir certaines entités en lecture seule ou en écriture -uniquement, 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 le fais pas voulez devenir trop technique.

Commençons par mettre en œuvre notre UserRepository. Je vais définir un IUserRepository interface qui contient des méthodes spécifiques aux utilisateurs, permettant ainsi au code d'appel de dépendre de cette abstraction lorsque nous injectons les implémentations concrètes:

interface IUserRepository extends IRepository {
    existsByUsername(username: string): Promise;
}

class UserRepository implements IUserRepository {
    // There are 6 methods to implement here all using the 
    // concrete type of `User` - Five from IRepository
    // and the one above.
}

Le référentiel de tâches serait similaire mais contiendrait des méthodes différentes selon les besoins de 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 la notion que nous envoyons ces modèles de domaine POTO et nous les sortons. Le code appelant n'a aucune idée de ce qu'est le mécanisme de persistance sous-jacent, et c’est le point.

La prochaine considération à faire 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 Knex Query Builder 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'ils rejoignent la logique métier car cela briserait 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 pouvant 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(dalOp: () => Promise) {
        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 {
    existsByUsername(username: string): Promise;
}

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 {
        return this.withErrorHandling(async () => {
            const dbUser = await this.dbContext()
                .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 User modèle de domaine avant de le renvoyer. Il s’agit du modèle Data Mapper et il est essentiel pour maintenir la séparation des préoccupations. DbUser est un mappage un à un vers 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, DbUsers ne quittera jamais le référentiel et sera mappé à un User modèle de domaine avant d'être renvoyé. Je n'ai pas montré le DbUser implémentation, mais cela pourrait être juste une simple classe ou 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 et à maintenir la sécurité de type et la réutilisation.

Enfin, aux fins 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 se moquer avec un cadre moqueur. Cette méthode oblige tout à s'appuyer sur les interfaces publiques plutôt que de permettre aux tests d'être couplés aux détails d'implémentation. Puisque les seules différences entre chaque référentiel sont les méthodes qu'ils choisissent d'ajouter sous le ISomethingRepository interface, nous pouvons créer un référentiel générique en mémoire et l'étendre dans les implémentations spécifiques au type:

class InMemoryRepository implements IRepository {
    protected entities: T() = ();
    
    public findById(id: string): Promise {
        const entityOrNone = this.entities.find(entity => entity.id === id);

        return entityOrNone 
            ? Promise.resolve(entityOrNone)
            : Promise.reject(new NotFound());
    }
    
    // Implement the rest of the IRepository 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 des référentiels de test en mémoire. En raison de méthodes comme findById, ce référentiel doit comprendre que les entités contiennent un id champ, c'est pourquoi la contrainte générique sur le IHasIdentity l'interface est nécessaire. Nous avons déjà vu cette interface – c'est ce que nos modèles de domaine ont mis en œuvre.

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 {
    public async existsByUsername(username: string): Promise {
        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 a besoin de savoir que les entités ont des champs tels que id et username, ainsi nous passons User comme paramètre générique. User implémente déjà IHasIdentity, donc la contrainte générique est satisfaite, et nous déclarons également que nous avons un username propriété aussi.

Désormais, lorsque nous souhaitons utiliser ces référentiels à partir de la couche de logique métier, 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 vraie application, nous déplacerions probablement l'appel vers emailService à une file d'attente de travaux afin de ne pas ajouter de latence à la demande et dans l'espoir de pouvoir effectuer des tentatives idempotentes en cas d'échec (- non pas que l'envoi d'e-mails soit particulièrement idempotent en premier lieu). De plus, le fait de transmettre tout l'objet utilisateur au service est également discutable. L'autre problème à noter est que nous pourrions nous trouver dans une position ici où le serveur se bloque après que l'utilisateur est persistant mais avant que l'e-mail ne soit envoyé. Il existe des schémas d'atténuation pour empêcher cela, mais à des fins de pragmatisme, une intervention humaine avec une exploitation forestière appropriée fonctionnera probablement très bien).

Et voilà – en utilisant le Repository Pattern avec la puissance des Generics, nous avons complètement découplé notre DAL de notre BLL et avons réussi à s'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 tout aussi sûrs pour les types à des fins de tests unitaires et d'intégration, permettant de véritables tests en boîte noire et sans implémentation. Rien de tout cela n'aurait été possible sans les types génériques.

En guise d'avertissement, je tiens à noter une fois de plus que cette implémentation de référentiel manque beaucoup. Je voulais garder l'exemple simple car l'accent est mis sur l'utilisation de génériques, c'est pourquoi je n'ai pas géré la duplication ni m'inquiéter des transactions. Les implémentations de référentiels décents nécessiteraient un article à part entière pour s'expliquer complètement et correctement, et les détails de la mise en œuvre changent selon que vous utilisez l'architecture N-Tier ou DDD. Cela signifie que si vous souhaitez utiliser le modèle de référentiel, vous devez ne pas regardez ma mise en œuvre ici comme une pratique exemplaire.

Exemples du monde réel – État de réaction et accessoires

L'état, la référence et le reste des hooks pour React Functional Components sont également génériques. Si j'ai une interface contenant des propriétés pour Tasks, et 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(());
    
    // A counter:
    // Notice, type of `number` is inferred automatically.
    const (counter, setCounter) = useState(0);
    
    return (
        

Counter Value: {counter}

    { tasks.map(task => (
  • )) }
); };

De plus, si nous voulons passer une série d'accessoires dans notre fonction, nous pouvons utiliser le générique React.FC tapez et accédez à props:

import React from 'react';

interface IProps {
    id: string;
    title: string;
    description: string;
}

export const TaskItem: React.FC = (props) => {
    return (
        

{props.title}

{props.description}

); };

Le type de props est automatiquement déduit d'être 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 simples collections aux approches de gestion des erreurs, en passant par l'isolation 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 moment 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 passer leur véritable pouvoir.

Éditorial fracassant(ra, yk, il)

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *