← Курс/Миксины: множественное наследование#165 из 257+30 XP

Миксины: множественное наследование

Проблема одиночного наследования

TypeScript (как и JavaScript) поддерживает только одиночное наследование — класс может расширять лишь один базовый класс. Миксины решают эту проблему, позволяя «примешивать» поведение из нескольких источников.

// Хотим: class Bird extends Flyable, Swimmable, Walkable
// Но нельзя: class Bird extends Animal, Flyable  // Ошибка TS!

Паттерн Constructor Type

Ключевой тип для миксинов — Constructor:

// Описывает любой класс-конструктор
type Constructor<T = {}> = new (...args: any[]) => T

// Миксин — функция, принимающая класс и возвращающая расширенный класс
function Serializable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    serialize(): string {
      return JSON.stringify(this)
    }

    static deserialize(json: string) {
      return JSON.parse(json)
    }
  }
}

Создание и применение миксинов

type Constructor<T = {}> = new (...args: any[]) => T

// Миксин 1: добавляет timestamp поля
function Timestamped<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    createdAt = new Date()
    updatedAt = new Date()

    touch() {
      this.updatedAt = new Date()
      return this
    }
  }
}

// Миксин 2: добавляет soft-delete
function SoftDeletable<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    deletedAt: Date | null = null

    delete() {
      this.deletedAt = new Date()
    }

    restore() {
      this.deletedAt = null
    }

    get isDeleted() {
      return this.deletedAt !== null
    }
  }
}

// Базовый класс
class Entity {
  constructor(public id: number) {}
}

// Применяем оба миксина
class User extends Timestamped(SoftDeletable(Entity)) {
  constructor(id: number, public name: string) {
    super(id)
  }
}

const user = new User(1, 'Алексей')
console.log(user.createdAt)   // Date
user.delete()
console.log(user.isDeleted)   // true
user.restore()
console.log(user.isDeleted)   // false

Миксин с интерфейсом

Для корректной типизации интерфейс и миксин объявляют вместе:

interface Activatable {
  isActive: boolean
  activate(): void
  deactivate(): void
}

function Activatable<TBase extends Constructor>(Base: TBase) {
  return class extends Base implements Activatable {
    isActive = false

    activate() { this.isActive = true }
    deactivate() { this.isActive = false }
    toggle() { this.isActive = !this.isActive }
  }
}

Реальный пример: EventEmitter миксин

type Constructor<T = {}> = new (...args: any[]) => T

function EventEmitter<TBase extends Constructor>(Base: TBase) {
  return class extends Base {
    private _handlers = new Map<string, Function[]>()

    on(event: string, handler: Function) {
      const list = this._handlers.get(event) ?? []
      this._handlers.set(event, [...list, handler])
      return this
    }

    off(event: string, handler: Function) {
      const list = this._handlers.get(event) ?? []
      this._handlers.set(event, list.filter(h => h !== handler))
    }

    emit(event: string, ...args: any[]) {
      (this._handlers.get(event) ?? []).forEach(h => h(...args))
    }
  }
}

class Store extends EventEmitter(class {}) {
  private state: Record<string, any> = {}

  set(key: string, value: any) {
    this.state[key] = value
    this.emit('change', key, value)
  }
}

Примеры

Паттерн миксинов: комбинирование поведений через функции-обёртки над классами

// TypeScript: type Constructor<T> = new (...args: any[]) => T
// function Mixin<TBase extends Constructor>(Base: TBase) { ... }
// В JS — то же самое, просто без аннотаций типов

// Миксин 1: Timestamped
function Timestamped(Base) {
  return class extends Base {
    constructor(...args) {
      super(...args)
      this.createdAt = new Date()
      this.updatedAt = new Date()
    }

    touch() {
      this.updatedAt = new Date()
      return this
    }

    getAge() {
      return Date.now() - this.createdAt.getTime()
    }
  }
}

// Миксин 2: SoftDeletable
function SoftDeletable(Base) {
  return class extends Base {
    constructor(...args) {
      super(...args)
      this.deletedAt = null
    }

    delete() {
      this.deletedAt = new Date()
      return this
    }

    restore() {
      this.deletedAt = null
      return this
    }

    get isDeleted() {
      return this.deletedAt !== null
    }
  }
}

// Миксин 3: EventEmitter
function EventEmitter(Base) {
  return class extends Base {
    constructor(...args) {
      super(...args)
      this._handlers = new Map()
    }

    on(event, handler) {
      if (!this._handlers.has(event)) this._handlers.set(event, [])
      this._handlers.get(event).push(handler)
      return this
    }

    off(event, handler) {
      const list = this._handlers.get(event) ?? []
      this._handlers.set(event, list.filter(h => h !== handler))
    }

    emit(event, ...args) {
      ;(this._handlers.get(event) ?? []).forEach(h => h(...args))
    }

    once(event, handler) {
      const wrapper = (...args) => {
        handler(...args)
        this.off(event, wrapper)
      }
      return this.on(event, wrapper)
    }
  }
}

// Миксин 4: Validatable
function Validatable(Base) {
  return class extends Base {
    validate() {
      const rules = this.constructor.validationRules ?? {}
      const errors = []
      for (const [field, rule] of Object.entries(rules)) {
        if (!rule(this[field])) {
          errors.push(`Поле "${field}" не прошло валидацию`)
        }
      }
      return errors
    }

    isValid() {
      return this.validate().length === 0
    }
  }
}

// Базовый класс
class Entity {
  constructor(id) { this.id = id }
  toString() { return `[${this.constructor.name} id=${this.id}]` }
}

// Комбинируем все миксины
class User extends EventEmitter(SoftDeletable(Timestamped(Entity))) {
  constructor(id, name, email) {
    super(id)
    this.name = name
    this.email = email
  }
}

User.validationRules = {
  name:  (v) => typeof v === 'string' && v.length >= 2,
  email: (v) => /S+@S+.S+/.test(v),
}

// Применяем Validatable к User
const ValidatableUser = Validatable(User)

// --- Демонстрация ---
console.log('=== Timestamps ===')
const user = new User(1, 'Алексей', 'alex@mail.ru')
console.log('createdAt:', user.createdAt instanceof Date)  // true
console.log('toString:', user.toString())

console.log('\n=== SoftDelete ===')
console.log('isDeleted:', user.isDeleted)   // false
user.delete()
console.log('isDeleted:', user.isDeleted)   // true
user.restore()
console.log('isDeleted:', user.isDeleted)   // false

console.log('\n=== EventEmitter ===')
user.on('update', (field, val) => {
  console.log(`Событие update: ${field} = ${val}`)
})
user.emit('update', 'name', 'Иван')
user.emit('update', 'email', 'ivan@mail.ru')

console.log('\n=== Validatable ===')
const vu = new ValidatableUser(2, 'А', 'не-email')
console.log('Ошибки:', vu.validate())  // два поля не прошли

const vu2 = new ValidatableUser(3, 'Ольга', 'olga@mail.ru')
console.log('Ошибки:', vu2.validate())  // []
console.log('isValid:', vu2.isValid())  // true