Les mixins en TypeScript : Composition de classes

Écrit par Fabien Schlegel

Fabien Schlegel

10 min

publié le : 01/11/2024

Les mixins en TypeScript : Composition de classes

Les mixins sont une fonctionnalité intéressante à utiliser pour améliorer la modularité et la réutilisabilité des classes.

Ils permettent d’enrichir des classes avec des fonctionnalités additionnelles sans recourir à l’héritage multiple, qui n’est pas supporté nativement par TypeScript.

Comprendre les mixins

Définition des mixins

Les mixins sont des fonctions qui peuvent être ajoutés à plusieurs classes pour leur fournir des fonctionnalités communes.

Contrairement à l’héritage classique où une classe hérite d’une seule classe parente, les mixins permettent d’assembler des comportements en combinant plusieurs sources.

Un mixin est une fonction qui prend une classe de base et retourne une nouvelle classe avec des fonctionnalités additionnelles.

Ce mécanisme permet de “mixer” différentes fonctionnalités dans une classe sans les inconvénients de l’héritage multiple.

Avantages de l’utilisation des mixins

Utiliser des mixins a un avantage significatif. Pouvoir ajouter des comportements et des méthodes utilitaires à des classes sans la complexité liée à l’héritage.

Ces méthodes sont réutilisables là ou c’est nécessaire sans dépendre d’une classe parent.

Mise en place des mixins en TypeScript

Syntaxe de base des mixins en TypeScript

Les mixins en TypeScript sont généralement implémentés sous forme de fonctions qui prennent une classe de base et retournent une nouvelle classe avec des fonctionnalités supplémentaires. Voici la syntaxe de base pour créer un mixin :

Définition d’un mixin :

function Timestamped<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    timestamp = new Date();

    getTimestamp() {
      return this.timestamp;
    }
  };
}

Dans cet exemple, Timestamped est un mixin qui ajoute une propriété timestamp et une méthode getTimestamp à une classe de base.

Application d’un mixin à une classe :

class Person {
  constructor(public name: string) {}
}

const TimestampedPerson = Timestamped(Person);

const person = new TimestampedPerson('Alice');
console.log(person.name); // Alice
console.log(person.getTimestamp()); // Affiche le timestamp actuel

Combiner plusieurs mixins

Il est également possible également de combiner plusieurs mixins. Voici comment procéder :

Combinaison de mixins :

const LoggableValidatableEmployee = Validatable(Loggable(Employee));

const employee2 = new LoggableValidatableEmployee('Charlie');
employee2.log('Checking validity');
console.log(employee2.isValid()); // true

En combinant plusieurs mixins, vous pouvez créer des classes enrichies de nouvelles fonctionnalités réutilisables.

Création de mixins simples

Exemple 1 : Mixin pour ajouter des fonctionnalités de journalisation

Un des usages courants des mixins est d’ajouter des fonctionnalités de journalisation à une classe. Voici comment créer un mixin qui ajoute cette capacité :

function Loggable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    log(message: string) {
      console.log(`${new Date().toISOString()}: ${message}`);
    }
  };
}

Ce mixin Loggable ajoute une méthode log qui affiche un message avec un timestamp.

Application du mixin de journalisation :

class Employee {
  constructor(public name: string) {}
}

const LoggableEmployee = Loggable(Employee);

const employee = new LoggableEmployee('Alice');
employee.log('Employee created'); // 2024-05-27T14:00:00.000Z: Employee created

En utilisant le mixin Loggable, la classe Employee est enrichie avec la méthode log.

Exemple 2 : Mixin pour ajouter des méthodes de validation

Un autre usage courant des mixins est d’ajouter des méthodes de validation. Voici comment créer un mixin pour cette fonction :

Définition du mixin de validation :

function Validatable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    isValid(): boolean {
      // Implémenter la logique de validation
      return true; // Exemple simple, retournera toujours true
    }
  };
}

Ce mixin Validatable ajoute une méthode isValid qui peut être utilisée pour vérifier la validité des instances.

Application du mixin de validation :

class Order {
  constructor(public orderId: number) {}
}

const ValidatableOrder = Validatable(Order);

const order = new ValidatableOrder(123);
console.log(order.isValid()); // true

La classe Order est maintenant enrichie avec la méthode isValid grâce au mixin Validatable.

Gestion des conflits et priorités entre mixins

Lors de la combinaison de plusieurs mixins, il est possible que des conflits surviennent, par exemple, si deux mixins ajoutent des méthodes ou des propriétés avec le même nom. Pour gérer ces conflits :

Renommage des méthodes : Si deux mixins définissent une méthode log, vous pouvez renommer une des méthodes pour éviter le conflit :

function LoggableV1<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    logV1(message: string) {
      console.log(`V1: ${new Date().toISOString()}: ${message}`);
    }
  };
}

function LoggableV2<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    logV2(message: string) {
      console.log(`V2: ${new Date().toISOString()}: ${message}`);
    }
  };
}

const CombinedLogger = LoggableV2(LoggableV1(Employee));

const employee3 = new CombinedLogger('Charlie');
employee3.logV1('Log from V1');
employee3.logV2('Log from V2');

Utilisation de super : Si vous souhaitez que les méthodes des mixins soient exécutées séquentiellement, vous pouvez utiliser super pour appeler la méthode d’un mixin dans une autre méthode :

function LoggableV3<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    log(message: string) {
      super.log(message);
      console.log(`V3: ${new Date().toISOString()}: Additional log`);
    }
  };
}

const CombinedLoggerV3 = LoggableV3(Loggable(Employee));

const employee4 = new CombinedLoggerV3('Dave');
employee4.log('Testing combined logging');

Exemple de gestion des événements avec des mixins

Définition du mixin de gestion des événements :

function EventEmitter<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    private events: { [key: string]: Function[] } = {};

    on(event: string, listener: Function) {
      if (!this.events[event]) {
        this.events[event] = [];
      }
      this.events[event].push(listener);
    }

    emit(event: string, ...args: any[]) {
      if (this.events[event]) {
        this.events[event].forEach((listener) => listener(...args));
      }
    }
  };
}

Application du mixin de gestion des événements avec d’autres mixins :

function Loggable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    log(message: string) {
      console.log(`${new Date().toISOString()}: ${message}`);
    }
  };
}

class Task {
  constructor(public description: string) {}
}

const EventfulLoggableTask = Loggable(EventEmitter(Task));

const task = new EventfulLoggableTask('Learn TypeScript Mixins');
task.on('start', () => task.log('Task started'));
task.emit('start'); // 2024-05-27T14:00:00.000Z: Task started

Dans cet exemple, Task est enrichi avec des capacités de journalisation et de gestion des événements.

Avantages de la composition avec des mixins

La composition de classes avec des mixins offre plusieurs avantages :

Réutilisabilité : Les mixins permettent de réutiliser des fonctionnalités communes à travers différentes classes sans répéter le code.

Flexibilité : La composition est plus flexible que l’héritage simple, permettant de combiner plusieurs comportements indépendants.

Modularité : Les mixins favorisent la modularité du code, facilitant ainsi la maintenance et les mises à jour.

Cas d’utilisation avancés

Mixin pour ajouter des fonctionnalités de sauvegarde et de chargement

Un cas d’utilisation avancé des mixins peut consister à ajouter des fonctionnalités de sauvegarde et de chargement aux objets, permettant ainsi de stocker et de restaurer l’état des instances. Voici comment créer et utiliser de tels mixins :

Définition du mixin de sauvegarde et de chargement :

function Serializable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    save(): string {
      return JSON.stringify(this);
    }

    static load<T extends typeof Base>(this: T, json: string): InstanceType<T> {
      const obj = new this();
      Object.assign(obj, JSON.parse(json));
      return obj as InstanceType<T>;
    }
  };
}

Ce mixin Serializable ajoute deux méthodes : save, qui convertit l’état de l’objet en une chaîne JSON, et load, qui restaure l’état à partir d’une chaîne JSON.

Application du mixin de sauvegarde et de chargement :

class Product {
  constructor(public name: string, public price: number) {}
}

const SerializableProduct = Serializable(Product);

const product = new SerializableProduct('Laptop', 1500);
const savedProduct = product.save();
console.log(savedProduct); // {"name":"Laptop","price":1500}

const loadedProduct = SerializableProduct.load(savedProduct);
console.log(loadedProduct); // Product { name: 'Laptop', price: 1500 }

Ici, la classe Product est enrichie avec des capacités de sauvegarde et de chargement, facilitant ainsi la gestion de l’état des objets.

Mixin pour ajouter des fonctionnalités de mise en cache

Un autre cas d’utilisation avancé peut ajouter des fonctionnalités de mise en cache pour améliorer les performances en évitant des calculs ou des requêtes répétées. Voici comment créer un mixin de mise en cache :

Définition du mixin de mise en cache :

function Cacheable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    private cache: { [key: string]: any } = {};

    getFromCache(key: string): any {
      return this.cache[key];
    }

    setCache(key: string, value: any): void {
      this.cache[key] = value;
    }
  };
}

Ce mixin Cacheable ajoute des méthodes pour stocker et récupérer des données mises en cache.

Application du mixin de mise en cache :

class DataFetcher {
  fetchData(): string {
    return 'Fetched Data';
  }
}

const CacheableDataFetcher = Cacheable(DataFetcher);

const dataFetcher = new CacheableDataFetcher();
const key = 'data';

if (!dataFetcher.getFromCache(key)) {
  const data = dataFetcher.fetchData();
  dataFetcher.setCache(key, data);
  console.log('Data cached');
}

console.log(dataFetcher.getFromCache(key)); // Fetched Data

Avec ce mixin, la classe DataFetcher peut désormais mettre en cache les données, améliorant ainsi l’efficacité des requêtes de données répétées.

Mixin pour ajouter des événements asynchrones

Pour gérer des opérations asynchrones de manière fluide, vous pouvez créer un mixin qui ajoute des capacités d’événements asynchrones. Voici comment procéder :

Définition du mixin d’événements asynchrones :

function AsyncEventEmitter<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    private asyncEvents: { [key: string]: Function[] } = {};

    onAsync(event: string, listener: Function) {
      if (!this.asyncEvents[event]) {
        this.asyncEvents[event] = [];
      }
      this.asyncEvents[event].push(listener);
    }

    async emitAsync(event: string, ...args: any[]) {
      if (this.asyncEvents[event]) {
        await Promise.all(this.asyncEvents[event].map((listener) => listener(...args)));
      }
    }
  };
}

Ce mixin AsyncEventEmitter permet de gérer des événements asynchrones en utilisant des Promises.

Application du mixin d’événements asynchrones :

class Downloader {
  async download(url: string): Promise<string> {
    return `Downloaded content from ${url}`;
  }
}

const AsyncDownloader = AsyncEventEmitter(Downloader);

const downloader = new AsyncDownloader();
downloader.onAsync('downloaded', async (content: string) => {
  console.log('Processing content:', content);
});

(async () => {
  const content = await downloader.download('<https://api.devoreur2code.com>');
  await downloader.emitAsync('downloaded', content);
})();

Dans cet exemple, la classe Downloader est enrichie avec des capacités d’événements asynchrones, permettant une gestion fluide des opérations asynchrones.

Mixin pour ajouter des fonctionnalités d’autorisation

Enfin, vous pouvez créer un mixin pour ajouter des capacités d’autorisation, permettant de vérifier les permissions avant d’exécuter certaines actions :

Définition du mixin d’autorisation :

function Authorizable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    private roles: string[] = [];

    setRoles(roles: string[]) {
      this.roles = roles;
    }

    canPerform(action: string): boolean {
      // Logique simple d'autorisation basée sur les rôles
      const permissions: { [key: string]: string[] } = {
        delete: ['admin'],
        edit: ['admin', 'editor'],
      };

      return permissions[action]?.some((role) => this.roles.includes(role)) || false;
    }
  };
}

Ce mixin Authorizable ajoute des méthodes pour définir les rôles et vérifier les permissions d’exécution.

Application du mixin d’autorisation :

class Document {
  constructor(public title: string) {}
}

const AuthorizableDocument = Authorizable(Document);

const doc = new AuthorizableDocument('Confidential Document');
doc.setRoles(['editor']);

console.log(doc.canPerform('edit')); // true
console.log(doc.canPerform('delete')); // false

La classe Document est maintenant capable de vérifier les autorisations avant de permettre certaines actions.

Ces cas d’utilisation avancés montrent comment les mixins peuvent être utilisés pour enrichir les classes avec des fonctionnalités puissantes et modulaires, facilitant ainsi la gestion de fonctionnalités complexes dans vos applications TypeScript.

Bonnes pratiques et pièges à éviter

Bonnes pratiques pour utiliser les mixins en TypeScript

Utilisez des mixins pour la composition de comportements communs :

Les mixins sont idéaux pour ajouter des fonctionnalités réutilisables à plusieurs classes sans répéter le code. Par exemple, des mixins pour la journalisation, la validation, ou la gestion des événements peuvent être appliqués à plusieurs classes pour partager ces comportements.

Gardez les mixins spécifiques :

Les mixins doivent être focalisés sur une seule responsabilité. Cela rend votre code plus modulaire et plus facile à maintenir. Un mixin trop complexe peut devenir difficile à comprendre et à déboguer.

Documentez les mixins et leur utilisation :

Étant donné que les mixins peuvent affecter profondément les classes auxquelles ils sont appliqués, il est essentiel de bien documenter leur comportement, leurs méthodes et leur impact. Une bonne documentation aide les autres développeurs à comprendre comment utiliser correctement les mixins.

Testez les mixins indépendamment :

Comme pour tout composant réutilisable, il est crucial de tester les mixins de manière isolée pour s’assurer qu’ils fonctionnent comme prévu. Écrivez des tests unitaires pour chaque mixin pour valider son comportement.

Pièges à éviter avec les mixins

Éviter les conflits de noms :

Un problème courant avec les mixins est le conflit de noms entre les méthodes et les propriétés. Pour éviter cela, utilisez des noms de méthodes spécifiques ou des préfixes pour différencier les méthodes des mixins. Par exemple, au lieu de log, utilisez logInfo ou logDebug.

Surveillance des dépendances et des effets de bord :

Les mixins peuvent introduire des dépendances implicites ou des effets de bord qui ne sont pas immédiatement évidents. Assurez-vous que les mixins ne modifient pas l’état global ou n’interfèrent pas de manière inattendue avec d’autres parties du code.

Gestion des appels superflus :

Lors de la composition de plusieurs mixins, il est important de gérer les appels super de manière appropriée pour éviter des boucles infinies ou des comportements inattendus. De manière générale, il vaut mieux les utiliser en dernier recours.

Éviter la surcharge excessive des classes :

L’ajout de trop de mixins à une seule classe peut la rendre lourde et difficile à gérer. Essayez de limiter le nombre de mixins appliqués à une classe pour maintenir la simplicité et la clarté du code.

Conclusion

En composant vos classes avec des mixins, vous pouvez créer des applications plus robustes, modulaires et maintenables.

Les mixins vous permettent d’éviter les limitations de l’héritage unique et d’adopter une approche plus flexible et modulaire pour structurer votre code en TypeScript.

Articles associés