Typescript les types avancés et patterns structurels

Les types avancés

Le type Record permet de définir un type d'objet qui contient des clés de propriété tirées d'un type spécifique et des valeurs d'un autre. Il est souvent utilisé pour déclarer des types de configuration.

Voici un exemple d'utilisation de Record pour définir un type de configuration :

 const serviceConfig: Record<string, string | number | boolean> = {
port: 3000,
basePath: "http://localhost",
enableStripePayments: false,
};
console.log(serviceConfig.port); // prints 3000

Lorsque vous utilisez le type Record, utiliser le type string pour les clés de propriété peut ne pas être très utile, car cela acceptera n'importe quelle chaîne comme nom de clé, même si elle n'est pas présente. Une méthode plus efficace consiste à fournir une liste de clés autorisées en utilisant des unions.

Voici un exemple qui illustre l'utilisation des types d'union pour définir des clés spécifiques avec Record :

 type ServiceConfigParams = "port" | "basePath" | "enableStripePayments";
const serviceConfigChecked: Record<ServiceConfigParams, string | number | boolean> = {
     port: 3000,
     basePath: "http://localhost",
     enableStripePayments: false,
  };

Le type Partial permet de créer un nouveau type avec toutes les clés de propriété facultatives d'un type existant. Cela peut être très utile lorsque vous définissez des paramètres de constructeur de classe avec des valeurs par défaut.

Voici un exemple d'utilisation de Partial pour définir un type User avec des clés de propriété facultatives :

 enum PRIORITY {
  DEFAULT,
  LOW,
  HIGH,
}
interface TodoItemProps {
  title: string;
  description: string;
  priority: PRIORITY;
}
class TodoItem {
  description?: string;
  title = "Default item title";
  priority = PRIORITY.DEFAULT;
  constructor(todoItemProps: Partial<TodoItemProps> =
     {}) {
     Object.assign(this, todoItemProps);
  }
}
 
const item = new TodoItem({ description: "Some description" });
console.debug(item.description); /* prints "Some description"*/
console.debug(item.title); /* prints "Some description"*/

Le type Partial permet de créer un nouveau type avec toutes les clés de propriété facultatives d'un type existant. Le type Required permet quant à lui de créer un nouveau type avec toutes les clés de propriété requises d'un type existant.

Voici un exemple d'utilisation de Partial et Required :

 type OriginalTodoItemProps = Required<Partial<TodoItemProps>>; // type is same as TodoItemProps

Le type Pick permet de créer un nouveau type avec uniquement les propriétés spécifiques sélectionnées parmi celles d'un type existant. Cette fonctionnalité est très utile lorsque vous avez une interface avec beaucoup de propriétés et que vous souhaitez en extraire une partie pour les utiliser dans vos composants.

Voici un exemple d'utilisation de Pick pour définir un type ButtonProps avec uniquement les propriétés label et onClick d'une interface ButtonInterface :

 type ButtonProps = Pick<HTMLAttributes<HTMLButtonElement>, 'onClick' | 'onSubmit' | 'className' | 'onFocus'>;
class LoggingButton extends React.Component<ButtonProps>

Le type Omit permet de créer un nouveau type en excluant une ou plusieurs propriétés spécifiques d'un type existant. Cette fonctionnalité est très utile lorsque vous souhaitez réutiliser toutes les propriétés existantes d'un type, mais que vous voulez en redéfinir certaines avec une signature différente.

Voici un exemple d'utilisation de Omit pour définir un type ButtonProps avec toutes les propriétés d'une interface ButtonInterface sauf la propriété size :

 type OriginalThemeProps = {
     colors: string[],
     elevations: string[],
     margins: string[],
     defaultTypography: string;
}
type CustomThemeProps {
     colors: Set<string>;
}
type ThemeProps = Omit<OriginalThemeProps, 'colors'> & { colors?: CustomThemeProps }

L'opérateur keyof permet de capturer toutes les clés d'un type. Cette fonctionnalité est très utile pour créer des types d'union à partir d'une variable ou d'un type existant.

Voici un exemple d'utilisation de keyof pour définir un type PersonKeys avec toutes les clés de propriété d'un type Person :

 interface SignupFormState {
  email: string;
  name: string;
}
interface ActionPayload {
  key: keyof SignupFormState;
  value: string;
}

Lorsque vous commencez à déclarer une variable avec le type d'union, keyof, votre éditeur de code (comme VSCode) complètera automatiquement le type pour vous, en listant toutes les clés de propriété disponibles pour ce type.

Voici un exemple pour définir une variable personKey avec le type PersonKeys :

En TypeScript, si deux types ont la même structure (les mêmes noms de propriétés), ils sont considérés comme compatibles. Parfois, vous pouvez vouloir contourner ce comportement et ne permettre que certains types spécifiques. C'est ce que permet de faire un système de type nominal. Brand est une propriété unique que vous pouvez attacher à un type pour le marquer comme spécial :

 type NominalTyped<Type, Brand> = Type & { __type: Brand };

Voici un exemple de cas d'utilisation:

 type Point2d = { x: number; y: number };
function distance1(first: Point2d, second: Point2d>) {
  return Math.sqrt(
     Math.pow(first.x - second.x, 2) + Math.pow(first.y – 
       second.y, 2)
  );
}

Vous pouvez appeler cette fonction en fournissant un objet avec la même structure même si son type n'est pas Point2d:

 distance1({x: 1, y: 2}, {x: 3, y: 4})

Vous devez modifier la signature de la fonction comme ceci pour forcer le type:

 function distance2(first: NominalTyped<Point2d, "Point2d">, second: NominalTyped<Point2d, "Point2d">)

L'unique symbol est une méthode supplémentaire pour émuler le typage nominal en JavaScript. Il peut être utilisé dans les déclarations de classe, comme illustré dans l'exemple suivant :

 class User {
     readonly private static __type: unique symbol = 
      Symbol();
     name: string;
     constructor(name: string) {
           this.name = name;
     }
}
type Account {
     name: string
}
function printUserName(o: User) {
     console.log(o.name);
}
printAccountName(new User("Theo"))
// printAccountName({name: "Alex"}) // fail to typecheck

L'utilisation d'unique symbol permet de marquer un type en tant que marque unique. Cela est utile pour limiter les types acceptables par une fonction, comme c'est le cas pour printUserName qui n'accepte que des objets de type User.

Lorsque le type passé à cette fonction possède la même structure qu'un objet de type Box, sa propriété value est extraite pour vérifier son type. Si le type extrait est identique au type attendu, alors le paramètre est considéré comme étant de type Box. Si le type extrait est E, le type extrait est retourné. Sinon, le type A est renvoyé tel qu'il est.

 interface Box<T> {
  value: T
}
type UnpackBox<A> = A extends Box<infer E> ? E : A

L'opérateur infer est utilisé pour inférer un type générique à partir d'un autre type dans TypeScript. Dans le cas où un type est défini dans A, nous vérifions si ce type étend le type Box en utilisant l'opérateur extends.

 type intStash = UnpackBox<{value: 10}> // type is number
type stringStash = UnpackBox<{value: "123"}> // type is string
type booleanStash = UnpackBox<true> // type is boolean

L'utilisation de ces concepts avancés de typage en TypeScript permet de générer des types sophistiqués qui modélisent plus précisément les objets de domaine que vous souhaitez utiliser dans votre application. En fin de compte, la production de ces types est bénéfique pour profiter du processus de compilation qui permet de détecter des erreurs telles que des mauvaises affectations ou des opérations invalides dès la phase de compilation. Cela permet de réduire le temps et les efforts nécessaires pour déboguer le code, ainsi que d'améliorer la qualité et la robustesse de l'application.

Modèles de conception de création

Lorsque vous déclarez des interfaces et des classes dans TypeScript, le compilateur prend ces informations et les utilise lors de l'exécution de vérifications de type ou d'autres assertions. Puis à l'exécution, lorsque le navigateur ou le serveur évalue le code, il crée et gère ces objets pendant toute la durée du cycle de vie de l'application. D'autres fois, vous pouvez créer des objets à la volée en utilisant un descripteur d'objet. Ces deux approches traitent de la création d'objets et, plus précisément, de la façon d'instancier un type d'objet et de le stocker quelque part:

  • Création d'un objet d'un type spécifique ou dans un but précis : Vous souhaitez créer un objet parfois lorsque l'application s'exécute, mais vous souhaitez conserver une manière cohérente et facile à utiliser de créer des instances de ces objets. De temps en temps, vous souhaitez contrôler les paramètres à utiliser, la catégorie d'objets à créer ou la manière de réutiliser certaines fonctionnalités et de cloner des objets en fonction de ceux existants.
  • Gestion du cycle de vie de l'objet : Vous souhaitez contrôler le nombre d'instances de l'objet existant et l'endroit où elles sont stockées. Vous voulez également pouvoir détruire en toute sécurité ces instances lorsqu'elles ne sont plus nécessaires.

En séparant le processus de création d'objet de sa mise en œuvre concrète, vous obtenez un système découplé. En utilisant des interfaces de méthodes analogues qui décrivent les types d'objets que vous créez au lieu de comment , vous pouvez fournir différents implémenteurs au moment de l'exécution sans modifier l'algorithme global ou la logique conditionnelle. Conserver les références d'objets au moment de l'exécution peut devenir problématique si vous les gérez de manière microscopique ou si vous les laissez dériver en tant que références. Vous souhaitez donc disposer d'une abstraction simple que vous pouvez utiliser pour louer ces objets à la demande.

Pattern singleton

Le terme Singleton décrit quelque chose qui n'a qu'une seule présence dans le programme. Vous l'utilisez lorsque vous souhaitez obtenir un seul objet au lieu de plusieurs objets différents pour plusieurs raisons. Par exemple, vous souhaitez peut-être ne conserver qu'une seule instance d'une classe particulière simplement parce que sa création est coûteuse ou qu'il n'est pas logique d'en conserver plusieurs pendant toute la durée de vie du programme.

Caractéristiques

  • Point d'accès global : un et un seul point d'accès de l'instance de l'objet.
  • L'instance est mise en cache quelque part : vous la stockez dans une instance classe en tant que variable statique, mais elle peut également être stockée dans un conteneur Inversion of Control ( IoC ).
  • L'instance est créée à la demande: L'instance n'est pas créée au moment où elle est déclarée, elle est créée selon le mode premier entré premier sorti (FIFO).
  • Instance unique par classe: l'instance est unique par classe dans le sens où différentes classes ont leurs propres Singletons.

Cas d'utilisation

Le singleton est utilisé pour contrôler l'accès aux ressources externes telles que les connexions à la base de données, les points de terminaison API ou les systèmes de fichiers. Cela signifie que vous ne voulez pas que deux ou plusieurs objets contiennent des références à ces ressources sans une sorte de coordination. Imaginez si 100 objets différents tentaient d'ouvrir et de modifier le même fichier ou la même connexion à la base de données. Cela créerait plusieurs problèmes avec le fichier lui-même car un objet pourrait voir des choses différentes d'un autre, et s'ils essaient tous les deux de modifier le fichier, le système d'exploitation sera invoqué pour prendre la décision finale. Le singleton permet de s'assurer qu'il n'y a qu'une seule instance d'un objet qui gère ces ressources externes, ce qui permet de contrôler l'accès de manière coordonnée et de garantir que toutes les modifications sont cohérentes.

Implémentation

Tout d'abord, pour implémenter le pattern singleton, vous devez empêcher la construction de nouvelles instances de la classe en mettant le constructeur à "private":

 export class Singleton {
  // Prevents creation of new instances
  private constructor() {}
}

Vous voulez empêcher la construction de nouveaux objets principalement pour éviter les erreurs et garantir qu'il n'y a qu'une seule instance de l'objet singleton. En effet, vous devez vous protéger contre la création manuelle des objets singleton par conception afin qu'aucune opération valide ne crée plus d'une instance au moment de l'exécution.

Ensuite, pour mettre en cache l'instance globale du Singleton, vous pouvez utiliser une variable static. Cette variable est propre à la classe et non à l'instance de la classe. Cela signifie que le runtime garantira qu'une seule instance de la variable est réservée pour chaque classe.

 export class Singleton {
  // Stores the singleton instance
  private static instance: Singleton;
  // Prevents creation of new instances
  private constructor() {}
} 

L'instance mise en cache est réservée à une seule par classe et elle est privée afin d'éviter qu'elle ne soit récupérée en dehors de la classe. Pour accéder à cette instance depuis l'intérieur de la classe, vous pouvez créer une méthode statique qui renvoie cette instance. Cette méthode doit être publique afin que les clients de la classe puissent y accéder. Cette méthode peut s'appeler "getInstance", "get" ou quelque chose de similaire. Elle doit également être statique afin que vous n'ayez pas besoin d'instancier la classe pour y accéder.

 export class UsersAPISingleton {
  private static instance: UsersAPISingleton;
  private constructor() {}
  static getInstance() {
     if (!UsersAPISingleton.instance) {
        UsersAPISingleton.instance = new UsersAPISingleton();
     }
     return UsersAPISingleton.instance;
  }
  getUsers(): Promise<any> {
     return Promise.resolve(["Alex", "John", "Sarah"]);
  }
}
const usersPromise = UsersAPISingleton.getInstance().getUsers();
usersPromise.then((res) => {
  console.log(res);
});

Notez que nous créons l'instance paresseusement, et non lorsque la classe est découverte au moment de l'exécution. Cela vous permet d'éviter tout effet secondaire du processus d'instanciation, tel qu'une utilisation accrue de la mémoire ou l'appel de services externes. Si cela n'est pas strictement requis, vous pouvez créer l'instance avec impatience dès le début. L'implémentation précédente représente les étapes algorithmiques minimales que vous devez inclure dans chaque classe qui est un Singleton.

De plus, vous pouvez tirer parti de certaines fonctionnalités de langage et d'environnement pour obtenir gratuitement le comportement Singleton. Explorons quelques implémentations alternatives dans les sections suivantes.

Utilisation des singletons de résolution de module : Au lieu de créer votre propre implémentation Singleton et que la classe mette en cache cette instance, vous pouvez tirer parti du mécanisme de chargement du système de module. Vous créez simplement une classe qui a une seule instance et vous l'exportez. Lorsque le module est chargé pour la première fois, la classe est instanciée et stockée en mémoire. Lorsque vous importez ce module dans d'autres parties de votre code, vous obtenez toujours la même instance. Par exemple :

 class ApiServiceSingleton {}

export default new ApiServiceSingleton();

L'utilisation du système de modules Node.js permet d'exporter une variable par défaut pointant vers une instance de ApiServiceSingleton pour contrôler les Singletons. Cependant, cela ressemble à une triche car vous déléguez essentiellement le contrôle du Singleton au système de modules et vous n'aurez pas la possibilité de modifier cette instance à moins que vous ne vous moquiez de l'ensemble du module à la place.

De plus, il est important de comprendre les mises en garde du système de module Node.js, car il met en cache les modules en fonction du chemin absolu requis de ce module. Tant que nous importons ce fichier et qu'il correspond au même chemin absolu, le système de module utilisera la même instance mise en cache. Cela peut ne pas être le cas si votre code réside dans les node_modules.

Une autre alternative pour contrôler les Singletons est d'utiliser un conteneur IoC (Inversion of Control). Inversify.js est un conteneur IoC populaire qui permet de tirer parti de ses capacités pour résoudre les singletons. Avec Inversify.js, vous pouvez déclarer votre classe Singleton en utilisant une annotation spéciale "@singleton()" sur la classe. Ensuite, vous pouvez utiliser le conteneur Inversify pour résoudre l'instance Singleton où vous en avez besoin.

 import "reflect-metadata";
import { injectable, Container } from "inversify";
interface UsersApiService {
  getUsers(): Promise<string[]>;
}
 
let TYPES = {
  UsersApiService: Symbol("UsersApiService"),
};
 
@injectable()
class UsersApiServiceImpl implements UsersApiService {
  getUsers(): Promise<string[]> {
     return Promise.resolve(["Alex", "John", "Sarah"]);
  }
}
const container = new Container();
container
  .bind<UsersApiService>(TYPES.UsersApiService)
  .to(UsersApiServiceImpl)
  .inSingletonScope();
 
container
  .get<UsersApiService>(TYPES.UsersApiService)
  .getUsers()
  .then((res) => console.log(res)); 

Chaque fois que vous demandez au conteneur de résoudre la liaison TYPES.UsersApiService, il renverra la même instance de UsersApiServiceImpl. L'utilisation de conteneurs IoC peut être considérée comme une approche intermédiaire lors de la mise en œuvre du modèle Singleton, car ils sont flexibles, faciles à tester et bien abstraits.

Variation

Une limitation du modèle Singleton est que vous ne pouvez pas transmettre les paramètres d'initialisation lors de la première instantiation de l'objet. Si vous deviez le faire, cela signifierait que vous auriez besoin de créer des objets différents à chaque fois.

Une solution est d'utiliser le modèle paramétrique Singleton, où vous conservez plusieurs instances en cache par une clé. Ainsi, lorsque vous passez deux paramètres différents, il doit renvoyer un objet différent, et passer les mêmes paramètres renverra le même objet :

 UsersAPISingleton.getInstance('/v1/users') === UsersAPISingleton.getInstance('/v1/users')
UsersAPISingleton.getInstance('/v1/users') !== UsersAPISingleton.getInstance('/v2/users')

Le principal problème avec le modèle paramétrique Singleton est de déterminer la clé unique pour chaque instance. Vous pouvez utiliser une fonction de hachage pour générer une clé unique à partir des paramètres passés. Par exemple :

 export class ParametricSingleton {
  private param: string;
  // Stores the singletons instances 
  private static instances: Map<string,
     ParametricSingleton>;
  // Prevents creation of new instances
  private constructor(param: string) {
     this.param = param;
  }
  // Method to retrieve instance
  static getInstance(param: string) {
     if (!ParametricSingleton.instances.has(param)) {
        ParametricSingleton.instances.set(param,
         new ParametricSingleton(param));
     }
     return ParametricSingleton.instances.get(param);
  }
}

La solution précédente fonctionne efficacement avec peu de paramètres de base, mais vous devrez créer votre propre schéma pour créer des clés uniques correspondant à chaque objet Singleton.

Pollution globale des instances Pas très testable Difficile à obtenir
Les Singletons sont utilisés comme une variable globale et de nombreux développeurs. L'utilisation de variables globales signifie ignorer toute flexibilité que vous pouvez obtenir des interfaces ou d'autres abstractions. Ceci est tout à fait valable, donc si vous décidez d'utiliser des Singletons, ils doivent être traités comme des objets statiques globaux qui effectuent un travail très spécifique et étroitement lié. En plus de tester les principes du singleton, si vous voulez tester le comportement de l'objet, vous devrez surmonter certaines restrictions. Le singleton est difficile à implémenter, surtout si vous prévoyez une testabilité et une initialisation paresseuse, et que vous souhaitez l'utiliser comme variable globale.

🔹 Pattern prototype Un prototype est une sorte d'objet qui tire son état initial et ses propriétés d'objets existants. L'idée principale est d'éviter d'avoir à créer manuellement un objet et de lui attribuer des propriétés à partir d'un autre objet. Au lieu de créer un nouvel objet en appelant l'opérateur new, vous suivez plutôt un chemin divergent. Vous construisez des objets qui adhèrent à l'interface Prototype, qui a une seule méthode, clone(). Lorsqu'elle est appelée, elle clonera l'instance existante de l'objet et ses propriétés internes. Vous pouvez éviter de dupliquer la logique de création d'un nouvel objet et d'attribution de fonctionnalités communes.

Quand l'utiliser ?

  • Vous avez un tas d'objets et souhaitez les cloner au moment de l'exécution : vous avez déjà créé des objets et y avez conservé des références au moment de l'exécution, et vous souhaitez obtenir rapidement des copies identiques sans revenir à la méthode Factory et attribuer à nouveau des propriétés.
  • Vous voulez éviter d'utiliser directement l'opérateur new : vous voulez éviter d'utiliser l'opérateur new car cela peut entraîner des frais généraux supplémentaires.

Implémentation Voici l'interface du type prototype :

Interface du type prototype

Vous voulez éviter d'utiliser l'opérateur new car cela peut entraîner des frais généraux supplémentaires :

Schéma de l'implémentation du pattern prototype

Désormais, les clients n'utiliseront et ne verront que les interfaces Prototype au lieu des objets réels. Cela leur permettra d'appeler la méthode clone pour renvoyer une copie de ces objets. Nous suivons le diagramme précédent pour implémenter ce modèle :

 interface HeroPrototype {
  clone(): HeroPrototype;
}

Ensuite, vous souhaitez implémenter au moins deux Herotypes de cette interface :

 class Wizard implements HeroPrototype {
  private spells: string[];
  private health: number;
  constructor(private name: string) {
     this.spells = [];
     this.health = 100;
  }
 
  clone(): Wizard {
     const cloned = Object.create(Wizard.prototype || null);
     Object.getOwnPropertyNames(this).map((key: string) => {
        if (key === "name") {
           cloned[key] = "Unknown";
        } else {
           cloned[key] = this[key];
        }
     });
 
     return cloned;
  }
}
class Warrior implements HeroPrototype {
  private weapon: string;
  private health: number;
  constructor(private name: string) {
     this.weapon = "Dagger";
     this.health = 150;
  }
  clone(): Warrior {
     const cloned = Object.create(Warrior.prototype || 
       null);
     Object.getOwnPropertyNames(this).map((key: string) => {
        if (key === "weapon") {
           cloned[key] = "Bare Hands";
        } else {
           cloned[key] = this[key];
        }
     });
 
     return cloned;
  }
}

Voici un exemple de code pour cloner un objet en utilisant le pattern prototype :

 let wiz: HeroPrototype = new Wizard("Theo");
let war: HeroPrototype = new Warrior("Mike");
console.debug(wiz.clone()); // Wizard { name: 'Unknown', spells: [], health: 100 }
console.debug(war.clone()); // Warrior { name: 'Mike', weapon: 'Bare Hands', health: 150 }

Parfois, vous souhaitez ignorer certaines propriétés telles que les identifiants ou les champs uniques lors du clonage, car vous pouvez avoir des exigences uniques.

Critique du modèle

Lorsque vous ne comptez que sur l' Prototype interface, vous devrez peut-être retranstyper l'objet vers le bon type d'instance car aucun autre champ n'est accessible.

De plus, créer votre propre méthode clone pour chaque objet qui implémente cette interface est fastidieuse. Si vous décidez de fournir une méthode clone de base puis d'utiliser l'héritage pour toutes les sous-classes, vous vous contredisez fondamentalement. Vous avez spécifiquement essayé d'éviter d'utiliser l'héritage lors de la création de nouveaux objets, mais maintenant vous l'utilisez pour cette méthode.

Pattern Builder

Builder est un modèle de conception de création que vous pouvez utiliser pour gérer la construction étape par étape d'objets pouvant avoir plusieurs représentations futures. Très souvent, vous créez des objets qui prennent plus de deux ou trois paramètres et nombre de ces paramètres ne sont pas connus à l'avance. Ils sont toutefois nécessaires pour initialiser l'objet avec le bon état. Nous pouvons avoir des objets complexes pour diverses raisons. Par exemple, le domaine métier peut vouloir attacher plusieurs attributs cohérents aux objets pour un accès plus facile. Dans d'autres cas, nous voulons avoir un modèle de classe conceptuel tel que User ou SearchQuery ou HTTPRequest. Au départ, vous ne pouvez avoir qu'une seule implémentation de la classe, mais lorsque vous souhaitez en créer plusieurs, vous vous retrouvez avec du code dupliqué.

Quand l'utiliser ?

  • Un ensemble commun d'étapes pour créer un objet : vous souhaitez fournir une interface avec des étapes communes pour créer un objet qui n'est lié à aucune implémentation. Ces étapes doivent être indépendantes et doivent toujours renvoyer un objet utilisable lorsqu'elles sont demandées.
  • Représentations multiples : Vous pouvez avoir plusieurs représentations d'un objet, peut-être sous forme de variantes ou de type de sous-classe. Si vous ne prévoyez pas ou n'avez pas besoin d'avoir plus d'une représentation à l'avenir, alors ce modèle semblera sur-conçu et inutile.

Pour toutes les raisons précédentes, vous devriez envisager d'utiliser le modèle Builder, car il vous permettra d'avoir une interface avec des étapes communes pour créer des objets complexes et la flexibilité de fournir plusieurs cibles à la demande. Si vous répondez non à l'une de ces questions, vous n'avez probablement pas encore besoin d'utiliser le modèle Builder. Vous voudrez voir comment toute exigence supplémentaire affecte les champs du modèle au fil du temps et vérifier à nouveau si vous devez refactoriser le modèle à l'aide de ce modèle.

Implémentation La classe Product peut avoir ses propres méthodes setter ou getter, mais il est important de noter qu'elle peut contenir plusieurs paramètres facultatifs.

Product Class

Vous avez besoin de l'interface qui décompose les étapes de création de cette classe Product dans un format réutilisable :

Product Builder Interface

Une fois que vous avez ces deux pièces, vous aurez également besoin d'un constructeur concret pour créer le premier type de représentation :

Product Builder Implementation

La clé ici est la méthode build(). Lorsqu'elle est appelée à partir de cette classe, elle renverra un type Product avec les attributs que nous avons définis précédemment. Vous pouvez accepter l'interface Builder comme paramètre ou comme variable privée :

Product Builder Implementation with Builder Interface

Tout d'abord, nous avons un type Product. Pour plus de commodité, nous pouvons utiliser un exemple réel avec des sites Web. Nous voulons avoir un Builder qui crée des objets Website. Nous pouvons avoir de nombreux paramètres différents pour un site Web où leurs représentations changent en fonction de leur type. Cependant, vous pouvez vous fier à certaines étapes génériques pour créer un modèle Website.

Commençons par le produit du site Web tel que nous l'avons décrit :

 class Website {
  constructor(
     public name?: string,
     public host?: string,
     public port?: number,
     public isPremium?: boolean
  ) {}
}

Vous devrez créer l'interface Builder pour fournir une liste des méthodes de création de sites Web autorisées avec la méthode build :

 interface WebsiteBuilder {
  setName(name: string): WebsiteBuilder;
  setHost(host: string): WebsiteBuilder;
  setPort(port: number): WebsiteBuilder;  setIsPremium
    (isPremium: boolean): WebsiteBuilder;
  build(): Website;
} 

Enfin, vous aurez besoin d'un constructeur concret qui crée une représentation spéciale d'un modèle Website.

 class PremiumWebsiteBuilder implements WebsiteBuilder {
  constructor(private website: Website) {
     this.clear();
  }
  setName(name: string): WebsiteBuilder {
     this.website.name = name;
     return this;
  }
  setHost(host: string): WebsiteBuilder {
     this.website.host = host;
     return this;
  }
  setPort(port: number): WebsiteBuilder {
     this.website.port = port;
     return this;
  }  setIsPremium(): WebsiteBuilder {
     this.website.isPremium = true;
     return this;
  }
  build(): Website {
     const website = this.website;
     this.clear();
     return website;
  }
 
  clear(): void {
     this.website = new Website();
     this.website.isPremium = true;
  }
} 

Nous avons fourni une représentation spécialisée d'un modèle de site Web premium tel qu'indiqué par la propriété isPremium. Nous avons mis en évidence le code qui préremplit cette propriété chaque fois que vous créez une instance du modèle Website. Voici un exemple d'appel :

 const wb = new PremiumWebsiteBuilder();
wb.setName("example").setHost("localhost").setPort(3000);
const website = wb.build();

Certaines implémentations modernes de ce modèle, utilisant TypeScript, tentent d'offrir une implémentation réutilisable qui utilise les proxys ES6 et Object.assign. C'est principalement pour éviter de répéter et de fournir manuellement des méthodes de définition pour toutes les propriétés Product.

 export type Builder<T> = {
  [k in keyof T]-?: (arg: T[k]) => Builder<T>;
} & {
  build(): T;
};
export function ModelBuilder<T>(): Builder<T> {
  const built: Record<string, unknown> = {};
  const builder = new Proxy(
     {},
     {
        get(target, prop) {
           if ("build" === prop) {
              return () => built;
           }
 
           return (x: unknown): unknown => {
              built[prop.toString()] = x;
              return builder;
           };
        },
     }
  );
 
  return builder as Builder<T>;
}
interface User {
  id: number;
  name: string;
  email: string;
} 
const user = ModelBuilder<User>()
  .id(1)
  .name("Theo")
  .email("theo@example.com")
  .build();
console.debug(user);

Dans le bloc de code précédent, nous avons mis en évidence l'utilisation de la classe Proxy qui délègue les appels de méthode et effectue les affectations. Si le message envoyé est build, alors il retourne l'objet, sinon, il attribue la propriété à l'objet. Cela fonctionne pour les affectations simples, mais si vous voulez avoir quelque chose de plus avancé, comme ajouter ou supprimer des éléments sur une liste, vous reviendrez à la case départ. En termes généraux, vous ne devriez vous en tenir à ces abstractions que pour les cas simples.

Critiques

  • Un constructeur concret pour chaque représentation : Pour créer différentes représentations, vous devrez écrire et maintenir des constructeurs distincts. Cela peut devenir un problème de maintenance si vous ne créez que des constructeurs qui ne diffèrent que par une seule propriété. Il est préférable de fournir un constructeur général pour la plupart des cas et d'utiliser un directeur pour créer des objets complexes ou d'attendre de devoir modéliser un nouveau constructeur concret pour des objets spéciaux nécessitant une approche différente.
  • Éviter les effets secondaires : Vous devrez éviter les effets secondaires lors de la création d'objets tels que les requêtes réseau ou celles qui nécessitent un accès au système d'exploitation. Tous les appels doivent effectuer des modifications modifiables ou immuables de manière atomique.
  • Peut être simplifié : Parfois, vous pouvez créer des objets dans TypeScript en faisant abstraction de certaines parties à l'aide d'une fonction au lieu d'utiliser ces interfaces Builder et ces méthodes de définition excessives. Si vous décidez d'utiliser une fonction, assurez-vous qu'elle est correctement documentée.

Pattern Factory

Ce modèle traite de la création d'objets et notamment avec la délégation de la création d'objets à l'aide de sous-classes. Les objets que vous souhaitez créer partagent généralement une caractéristique commune ; ils sont de même nature ou de même type, ou ils s'inscrivent dans une hiérarchie. Vous utilisez une interface avec une méthode create distincte, puis vous fournissez des classes concrètes qui implémentent cette factory et construisent des objets d'une sous-classe particulière. Ensuite, cette interface factory peut être utilisée aux endroits où vous avez des types codés en dur dans des paramètres ou des variables.

Un objet factory est une abstraction responsable de la création d'objets. La façon dont il les crée est cependant le principal différenciateur. Lorsque vous avez plusieurs types d'objets qui héritent d'une classe similaire ou ont un rôle similaire, vous pouvez constater que le passage de chaque type en tant que paramètre est fastidieux. Vous devrez créer toutes ces différentes versions de fonctions ou méthodes pour gérer ces divers types.

Ainsi, au lieu d'envisager d'utiliser l'opérateur new pour créer ces objets manuellement, nous définissons une méthode Factory appelée create qui accepte soit une interface, soit une variable de type décrivant ce que vous souhaitez créer. Cette méthode Factory va extraire tous les détails internes de la création du bon objet et le retourner pour vous. L'utilisation de la méthode Factory suppose que vous souhaitez éviter la méthode traditionnelle de création d'objets et que vous décrivez plutôt ce que vous souhaitez créer.

Quand utiliser la méthode factory ?

Quand vous avez une liste de divers objets avec une relation parent-enfant, tels que Element, HTMLElement, HTMLSpanElement, etc., il n'est pas idéal de les créer généralement à l'aide de l'opérateur new. Vous souhaitez avoir une description des éléments que vous souhaitez créer et la factory les créera pour vous.

Parfois, au lieu de passer une description et de laisser la fabrique créer l'objet en question, on souhaite avoir une fabrique spécialisée pour construire cet objet. De cette façon, vous pouvez utiliser une interface pour cette usine et transmettre des objets Method Factory pertinents. Lors de l'exécution, lorsque vous implémentez cette interface, en utilisant polymorphism , il sera appeler la bonne méthode Factory. Dans tous les cas, vous obtenez le même résultat.

Implémentation

Pour commencer, vous avez l'interface Product qui décrit les méthodes publiques des produits concrets :

Diagramme de l'interface Product

Ensuite, vous avez une ou plusieurs implémentations concrètes de cette interface dans lesquelles vous souhaitez vous spécialiser :

Diagramme d'implémentation concrète d'un produit

De l'autre côté, nous avons également une paire d'interfaces Factory et d'objets factory concrets qui représentent la création de nouvelles instance Product :

Diagramme de l'interface et de l'implémentation de la Factory

En regardant le diagramme précédent, il est logique d'utiliser ce modèle lorsque vous avez au moins deux types de produits ou plus que vous souhaitez créer. C'est relativement facile d'implémenter le diagramme de classes précédent dans TypeScript.

 interface Weapon {
  getName(): string;
  getDamage(): number;
  getRange(): number;
}

Vous voulez avoir deux sortes de Weapon: LongSword et LongBow.

 class LongSword implements Weapon {
  getName(): string {
     return "LongSword";
  }
  getDamage(): number {
     return 10;
  }
  getRange(): number {
     return 2;
  }
}
class LongBow implements Weapon {
  getName(): string {
     return "LongBow";
  }
  getDamage(): number {
     return 8;
  }
  getRange(): number {
     return 16;
  }
} 

En général, les modèles d'une application sont décrits de cette manière. Vous cherchez à éviter l'utilisation de l'opérateur "new" pour créer ces modèles, et préférez plutôt définir une "factory" pour chacune des armes :

 interface WeaponFactory {
  create(): Weapon;
}
class LongSwordFactory implements WeaponFactory {
  create(): Weapon {
     return new LongSword();
  }
}
class LongBowFactory implements WeaponFactory {
  create(): Weapon {
     return new LongBow();
  }
}

En utilisant la méthode Factory, vous instanciez les objets une seule fois pendant toute la durée de vie du programme, puis vous pouvez les réutiliser chaque fois que vous avez besoin d'une interface WeaponFactory.

 const lbf = new LongBowFactory();
const lsf = new LongSwordFactory();
const factories: WeaponFactory[] = [lbf, lsf, lbf];
factories.forEach((f: WeaponFactory) => {
  console.debug(f.create());
});
// Prints
LongBow {}
LongSword {}
LongBow {} 

Developpeur et architecte passionné, qui souhaite partagé son univers et ses découvertes afin de rendre les choses plus simple pour chacun