3

I have been using a strongly typed event emitter interface in Typescript for a while, but now I need it to support subclasses that add own events to it. At some point Typescript fails to be aware of the base class events.

Here is the code in a condensed version (Playground Link):

type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;

type AddParameters<ListenersT, EventT> =
    ListenersT extends (...args: infer ArgsT) => void
        ? (event: EventT, ...args: ArgsT) => Promise<boolean>
        : never;

type EmitSignatures<ListenersT> =
    { [EventT in keyof ListenersT]: AddParameters<ListenersT[EventT], EventT> };
type EmitAll<ListenersT> = UnionToIntersection<EmitSignatures<ListenersT>[keyof ListenersT]>

type OnSignatures<ListenersT, ReturnT> =
    { [EventT in keyof ListenersT]: (event: EventT, listener: ListenersT[EventT]) => ReturnT };
type OnAll<ListenersT, ReturnT> =
    UnionToIntersection<OnSignatures<ListenersT, ReturnT>[keyof ListenersT]>;

type EventEmitter<ListenersT> = EmitterInterface<ListenersT>;

export interface EmitterInterface<ListenersT>
{
    emit: EmitAll<ListenersT>;
    on: OnAll<ListenersT, this>;
}

/////////////////////////////////////////////////////////////////////////////////////////////

interface VehicleEvents
{
    accelerate(acceleration: number): void;
    brake(deceleration: number): void;
}

interface BusEvents extends VehicleEvents
{
    doorStateChange(front: boolean, middle: boolean, rear: boolean): void
}

interface Vehicle<E extends VehicleEvents> extends EventEmitter<E>
{
    onSig: OnSignatures<E, this>;
    onSigs: OnSignatures<E, this>[keyof E];
}

class Vehicle<E extends VehicleEvents>
{
    public constructor()
    { this.on('brake', () => this.flashBrakeLights()); } // supposed to work?

    public flashBrakeLights(): void {}

    public hitTheGas(strength: number): void
    { this.emit('accelerate', strength * 42); } // supposed to work?

    public test(): void
    {
        this.onSig.accelerate;
        this.onSig.brake;
        this.onSigs('accelerate', (a) => undefined); // error I don't understand
        this.onSigs('brake', (d) => undefined); // error I don't understand
        this.onSigs('foo', () => undefined); // supposed to error
    }
}

interface Bus extends EventEmitter<BusEvents> {}

class Bus extends Vehicle<BusEvents>
{
    public doorState: [boolean, boolean, boolean] = [false, false, false];

    public constructor()
    {
        super();
        this.on('accelerate', () => {
            this.door(0, false);
            this.door(1, false);
            this.door(2, false);
        });
    }

    public door(index: number, state: boolean): void
    {
        this.doorState[index] = state;
        this.emit('doorStateChange', ...this.doorState);
    }
}

export const bus = new Bus();

The E type is declared as extension of VehicleEvents, which should be enough to let Typescript know there are the accelerate and brake events, shouldn't it?

Any explanations for why this doesn't work? Any ideas for how to fix this or to achieve what I need in another way?

Neonit
  • 680
  • 8
  • 27
  • Inside `Vehicle` the problem is that `E` is not known, so all that conditional and mapped type magic will not be fully resolvable and so you will get errors. Also `onSigs` is a union of signatures so that will not work as you expect it to even from outside (the parameters have to be intersections of all possibilities. – Titian Cernicova-Dragomir Apr 19 '19 at 15:25
  • Yes `E` is not known inside `Vehicle`, but it *is* known that it extends `VehicleEvents`. So I assume Typescript doesn't even resolve it as far as possible. Considering `onSigs` I thought of that as well, but shouldn't the first parameter be a union of all event names then and thus error as well (it only complains about the handler part for me)? Also, any ideas how I could achieve what I need? :( – Neonit Apr 20 '19 at 14:43
  • 1
    I will think about it .. I answered a similar question a while ago https://stackoverflow.com/questions/50369299/can-i-reuse-the-parameter-definition-of-a-function-in-typescript/50375712#50375712, but it was not extensible either .. I tried a couple of things but did't get it to work the way you want unfortunately – Titian Cernicova-Dragomir Apr 20 '19 at 14:45
  • [sub-events](https://github.com/vitaly-t/sub-events) is a fully-extensible alternative ;) If you want details - just ask, and I will publish a detailed answer. – vitaly-t Mar 12 '20 at 07:45

1 Answers1

3

The problem is that inside the class those fancy conditional types (not sure where they came from cough) can't be resolved if they still contain unresolved type parameters. So while your approach to use a generic type parameter for extensibility seems like a good idea, the effect is that it makes on and emit unusable inside the class.

One solution is not to use a type parameter, just use event interface itself. The problem with that (as you undoubtedly found) is that it makes the class impossible to extend, as any derived version of on and emit will not be compatible with the base type version.

To fix this issue we can use a function that removes on and emit from the base type. This is a bit hackish but I don't think there is a better way.

interface VehicleEvents {
  accelerate(acceleration: number): void;
  brake(deceleration: number): void;
}

interface BusEvents extends VehicleEvents {
  doorStateChange(front: boolean, middle: boolean, rear: boolean): void
}

interface Vehicle extends EventEmitter<VehicleEvents> {}

class Vehicle {
  public constructor() {
    this.on('brake', () => this.flashBrakeLights()); //ok 
  }

  public flashBrakeLights(): void { }

  public hitTheGas(strength: number): void { this.emit('accelerate', strength * 42); } // ok

}

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
interface Bus extends EventEmitter<BusEvents> { }

function extendEmitter<TBaseCtor extends new (...a: any[])=> any>(ctor: TBaseCtor){
  return ctor as (new (...a: ConstructorParameters<TBaseCtor>) => Omit<InstanceType<TBaseCtor>, 'on' | 'emit'>)
}
class Bus extends extendEmitter(Vehicle) {
  public doorState: [boolean, boolean, boolean] = [false, false, false];

  public constructor() {
    super();
    this.on('accelerate', () => {
      this.door(0, false);
      this.door(1, false);
      this.door(2, false);
    });
  }

  public door(index: number, state: boolean): void {
    this.doorState[index] = state;
    this.emit('doorStateChange', ...this.doorState);
  }

}

export const bus = new Bus();

The above version does not ensure that the newly derived implements the base event aproriately. We can write a version that would validate that but it would require a small chaneg to the original definitions to allow us to extract the event interface from the base type:

type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;

type AddParameters<ListenersT, EventT> =
    ListenersT extends (...args: infer ArgsT) => void
        ? (event: EventT, ...args: ArgsT) => Promise<boolean>
        : never;

type EmitSignatures<ListenersT> =
    { [EventT in keyof ListenersT]: AddParameters<ListenersT[EventT], EventT> };
type EmitAll<ListenersT> = UnionToIntersection<EmitSignatures<ListenersT>[keyof ListenersT]>

type OnSignatures<ListenersT, ReturnT> =
    { [EventT in keyof ListenersT]: (event: EventT, listener: ListenersT[EventT]) => ReturnT };
type OnAll<ListenersT, ReturnT> =
    UnionToIntersection<OnSignatures<ListenersT, ReturnT>[keyof ListenersT]>;

type EventEmitter<ListenersT> = EmitterInterface<ListenersT>;

export interface EmitterInterface<ListenersT>
{
    emit: EmitAll<ListenersT>;
    on: OnAll<ListenersT, this> & {__source: ListenersT}; // do not use __source, just here to allow EventTypes to work
}

type EventTypes<T> = T extends EventEmitter<infer U> ? U : never;

/////////////////////////////////////////////////////////////////////////////////////////////

interface VehicleEvents {
  accelerate(acceleration: number): void;
  brake(deceleration: number): void;
}

interface BusEvents extends VehicleEvents {
  doorStateChange(front: boolean, middle: boolean, rear: boolean): void
}

interface Vehicle extends EventEmitter<VehicleEvents> {}

class Vehicle {
  public constructor() {
    this.on('brake', () => this.flashBrakeLights()); //ok 
  }

  public flashBrakeLights(): void { }

  public hitTheGas(strength: number): void { this.emit('accelerate', strength * 42); } // ok

}

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>
interface Bus extends EventEmitter<BusEvents> { }


function extendEmitter<TBaseCtor extends new (...a: any[])=> any>(ctor: TBaseCtor){
  return function<TEvents extends EventTypes<InstanceType<TBaseCtor>>>(){
    return ctor as (new (...a: ConstructorParameters<TBaseCtor>) => Omit<InstanceType<TBaseCtor>, 'on' | 'emit'> & EventEmitter<TEvents>)
  }
}

class Bus extends extendEmitter(Vehicle)<BusEvents>() {
  public doorState: [boolean, boolean, boolean] = [false, false, false];

  public constructor() {
    super();
    this.on('accelerate', () => {
      this.door(0, false);
      this.door(1, false);
      this.door(2, false);
    });
  }

  public door(index: number, state: boolean): void {
    this.doorState[index] = state;
    this.emit('doorStateChange', ...this.doorState);
  }

}

export const bus = new Bus();
Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Thank you very much for finding a solution. I don't understand the second part, though. Just using the changes from the first code block works in my test. Maybe this is because our implementations differ? – Neonit Apr 23 '19 at 08:29
  • @Neon perhaps, it you provide a repo with the code I can have a look. We can also chat on gitter if you need more info, this is an interesting topic :) – Titian Cernicova-Dragomir Apr 23 '19 at 08:32