Mixins in TypeScript : Class composition

Written by Fabien Schlegel

Fabien Schlegel

9 min

published: 11/1/2024

Mixins in TypeScript : Class composition

Mixins are an interesting feature to use to enhance the modularity and reusability of classes.

They allow you to enrich classes with additional functionality without resorting to multiple inheritance, which is not natively supported by TypeScript.

Understanding mixins

Defining mixins

Mixins are functions that can be added to several classes to provide them with common functionality.

Unlike classical inheritance, where a class inherits from a single parent class, mixins allow you to assemble behaviors by combining several sources.

A mixin is a function that takes a base class and returns a new class with additional functionality.

This mechanism makes it possible to “mix” different functionalities in a class without the disadvantages of multiple inheritance.

Benefits of using mixins

Using mixins has a significant advantage. You can add behaviors and utility methods to classes without the complexity of inheritance.

These methods can be reused where necessary, without depending on a parent class.

Setting up mixins in TypeScript

Basic syntax for TypeScript mixins

TypeScript mixins are generally implemented as functions that take a base class and return a new class with additional functionality. Here’s the basic syntax for creating a mixin:

Defining a mixin :

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

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

In this example, Timestamped is a mixin that adds a timestamp property and a getTimestamp method to a base class.

Applying a mixin to a class :

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

Combine several mixins

It’s also possible to combine several mixins. Here’s how to do it:

Combining mixins :

const LoggableValidatableEmployee = Validatable(Loggable(Employee));

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

By combining several mixins, you can create classes enriched with new, reusable functions.

Creating simple mixins

Example 1: Mixin to add logging functionality

One of the most common uses of mixins is to add logging functionality to a class. Here’s how to create a mixin that adds this capability.

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

This Loggable mixin adds a log method that displays a message with a timestamp.

Application of the logging mixin :

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

Using the Loggable mixin, the Employee class is enriched with the log method.

Example 2: Mixin for adding validation methods

Another common use of mixins is to add validation methods. Here’s how to create a mixin for this function:

Validation mixin definition :

function Validatable<TBase extends new (...args: any[]) => {}>(Base: TBase) {
  return class extends Base {
    isValid(): boolean {
      // Implement the validation logic
      return true; // Simple example, will always return true
    }
  };
}

This Validatable mixin adds an isValid method that can be used to check the validity of instances.

Applying the validation mixin:

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

const ValidatableOrder = Validatable(Order);

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

The Order class is now enriched with the isValid method thanks to the Validatable mixin.

Managing conflicts and priorities between mixins

When combining several mixins, conflicts may arise, for example, if two mixins add methods or properties with the same name. To manage these conflicts:

Method renaming :

If two mixins define a log method, you can rename one of the methods to avoid conflict:

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');

Use of super:

If you want mixin methods to be executed sequentially, you can use super to call a mixin method in another method :

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');

Example of event management with mixins

Event management mixin definition :

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));
      }
    }
  };
}

Applying the event management mixin with other 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

In this example, Task is enhanced with logging and event management capabilities.

Advantages of composing with mixins

Class composition with mixins offers several advantages:

Reusability: Mixins enable common functionality to be reused across different classes without repeating code.

Flexibility: Mixins are more flexible than simple inheritance, allowing you to combine several independent behaviors.

Modularity: Mixins promote code modularity, facilitating maintenance and updates.

Advanced use cases

Mixin to add save and load functionality

An advanced use case for mixins is to add save and load functionality to objects, enabling instance state to be stored and restored. Here’s how to create and use such mixins:

Save and load mixin definition :

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>;
    }
  };
}

This Serializable mixin adds two methods: save, which converts the object’s state into a JSON string, and load, which restores the state from a JSON string.

Application of the save and load mixin :

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 }

Here, the Product class is enriched with save and load capabilities, making it easier to manage the state of objects.

Mixin to add caching functionality

Another advanced use case is to add caching functionality to improve performance by avoiding repeated calculations or queries. Here’s how to create a caching mixin:

Caching mixin definition :

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;
    }
  };
}

This Cacheable mixin adds methods for storing and retrieving cached data.

Applying the caching mixin :

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

With this mixin, the DataFetcher class can now cache data, improving the efficiency of repeated data requests.

Mixin for adding asynchronous events

To handle asynchronous operations smoothly, you can create a mixin that adds asynchronous event capabilities. Here’s how to do it:

Defining the asynchronous event mixin :

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)));
      }
    }
  };
}

This AsyncEventEmitter mixin allows asynchronous events to be managed using Promises.

Application of the asynchronous events mixin :

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);
})();

In this example, the Downloader class is enriched with asynchronous event capabilities, enabling smooth management of asynchronous operations.

Mixin to add authorization capabilities

Finally, you can create a mixin to add authorization capabilities, allowing you to check permissions before executing certain actions:

Definition of authorization mixin :

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;
    }
  };
}

This Authorizable mixin adds methods for defining roles and checking execution permissions.

Application of the authorization mixin :

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

The Document class is now able to check authorizations before allowing certain actions.

These advanced use cases show how mixins can be used to enrich classes with powerful, modular functionality, making it easier to manage complex features in your TypeScript applications.

Best practices and pitfalls to avoid

Best practices for using mixins in TypeScript

Use mixins to compose common behaviors :

Mixins are ideal for adding reusable functionality to multiple classes without repeating code. For example, mixins for logging, validation or event handling can be applied to several classes to share these behaviors.

Keep mixins specific:

Mixins should be focused on a single responsibility. This makes your code more modular and easier to maintain. A mixin that is too complex can become difficult to understand and debug.

Document mixins and their use:

Since mixins can profoundly affect the classes to which they are applied, it’s essential to properly document their behavior, methods and impact. Good documentation helps other developers understand how to use mixins correctly.

Test mixins independently:

As with any reusable component, it’s crucial to test mixins in isolation to make sure they work as intended. Write unit tests for each mixin to validate its behavior.

Pitfalls to avoid with mixins

Avoid name conflicts:

A common problem with mixins is name conflicts between methods and properties. To avoid this, use specific method names or prefixes to differentiate methods from mixins. For example, instead of log, use logInfo or logDebug.

Monitor dependencies and side effects:

Mixins can introduce implicit dependencies or edge effects that are not immediately obvious. Make sure that mixins do not alter the overall state or interfere unexpectedly with other parts of the code.

Super call management:

When composing multiple mixins, it’s important to handle super calls appropriately to avoid infinite loops or unexpected behavior. As a general rule, it’s best to use them as a last resort.

Avoid overloading classes:

Adding too many mixins to a single class can make it unwieldy and difficult to manage. Try to limit the number of mixins applied to a class to keep the code simple and clear.

Conclusion

By composing your classes with mixins, you can create more robust, modular and maintainable applications.

Mixins allow you to avoid the limitations of single inheritance and adopt a more flexible, modular approach to structuring your code in TypeScript.

Related Articles