Les mixins en TypeScript : Composition de classes
Fabien Schlegel
publié le : 01/11/2024
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.