Search code examples
typescripttypesmixinsunions

Use Mixins with union types in Typescript


I have a simple system where I generate classes by inheriting them from separate base classes and then mixing in another class to each of them. Here is my mixin class:

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

/**
 * Based on the Mixin idea explained here:
 * https://mariusschulz.com/blog/typescript-2-2-mixin-classes
 *
 * @param base
 * @constructor
 */
export function EntityServices<TBase extends Constructor>(base: TBase) {
  return class extends base {
    private _components = {};

    public addComponent(component: Component) {
      throw new Error('Not implemented');
    }

    public removeComponent(component: Component) {
      throw new Error('Not implemented');
    }
  };
}

This mixin is used in another module to create few classes like so:

class ContainerEntityBase extends Phaser.GameObjects.Container {}
class ImageEntityBase extends Phaser.GameObjects.Image {}
class SpriteEntityBase extends Phaser.GameObjects.Sprite {}
class TextEntityBase extends Phaser.GameObjects.Text {}

export const ContainerEntity = EntityServices(ContainerEntityBase);
export const ImageEntity = EntityServices(ImageEntityBase);
export const SpriteEntity = EntityServices(SpriteEntityBase);
export const TextEntity = EntityServices(TextEntityBase);

// Type definitions have to be exported separately so that they can be used as types elsewhere, not as values
// Same name with values (classes) does not matter since TS stores values and types into separate
// namespaces.
export type ContainerEntity = InstanceType<typeof ContainerEntity>;
export type ImageEntity = InstanceType<typeof ImageEntity>;
export type SpriteEntity = InstanceType<typeof SpriteEntity>;
export type TextEntity = InstanceType<typeof TextEntity>;
export type BlackbirdEntity = ContainerEntity | ImageEntity | SpriteEntity | TextEntity;

As you can see, I have exported both actual created classes and their types with one extra union type BlackBirdEntity. Sometimes I'll use variables which may be any of the generated types, since in these cases these instances are operated on by their common mixed in interface.

Next I have the following simple definition which uses the union type:

import { Component } from '../core/Component';
import { BlackbirdEntity } from '../core/entities';

export interface IEntityDefinition {
  name: string;
  components: Component[];
  type: BlackbirdEntity;
}

And I use it like this to create an object which implements the said interface:

import { SpriteEntity } from '../core/entities';
import { IEntityDefinition } from './EntityDefinition';

const clickableEntity: IEntityDefinition = {
  components: [],
  name: 'Clickable',
  type: SpriteEntity
};

However, this gives me the following error on the IDE with SpriteEntity highlighted:

TS2322: Type '{ new (...args: any[]): EntityServices<typeof SpriteEntityBase>.(Anonymous class); prototype: EntityServices<any>.(Anonymous class); } & typeof SpriteEntityBase' is not assignable to type 'BlackbirdEntity'.   Type '{ new (...args: any[]): EntityServices<typeof SpriteEntityBase>.(Anonymous class); prototype: EntityServices<any>.(Anonymous class); } & typeof SpriteEntityBase' is not assignable to type 'EntityServices<typeof TextEntityBase>.(Anonymous class) & TextEntityBase'.     Type '{ new (...args: any[]): EntityServices<typeof SpriteEntityBase>.(Anonymous class); prototype: EntityServices<any>.(Anonymous class); } & typeof SpriteEntityBase' is missing the following properties from type 'EntityServices<typeof TextEntityBase>.(Anonymous class)': _components, addComponent, removeComponent

Why? And how to fix this? The error seems to suggest that the SpriteEntity is missing properties which are in reality in TextEntity's parent class. So is there any way to tell the compiler this kind of type should be ok, even if their parents' definition differs?


Solution

  • Your problem is that IEntityDefinition wanted its type property to be an instance of BlackbirdEntity, not a constructor of one. You might be confused because for a class the constructor value generally shares a name with the instance type, even though they are not the same thing.

    Anyway, you've already got a Constructor<T> type alias sitting around, so let's use it:

    export interface IEntityDefinition {
      name: string;
      components: Component[];
      type: Constructor<BlackbirdEntity>; // you want a constructor, not an instance here
    }
    

    This should make your clickableEntity variable initialization compile without errors.

    Hope that helps; good luck!