Search code examples
typescripteventscode-generationmixinsdispatch

Automatically generating and chaining mixins for a typescript class


Here is what I have

interface BaseEvent {
    type: string;
    payload: any;
}

interface EventEmitter {
    emit(event: BaseEvent): void;
}

class BaseClass {
    constructor(protected eventEmitter: EventEmitter) {}

    emit(event: BaseEvent) {
        this.eventEmitter.emit(event);
    }
}

interface CustomEvent1 extends BaseEvent {
    type: 'custom1';
    payload: {
        message: string;
    };
}

interface CustomEvent2 extends BaseEvent {
    type: 'custom2';
    payload: {
        value: number;
    };
}

const eventEmitter: EventEmitter = {
    emit(event: BaseEvent) {
        console.log(`Event type: ${event.type}, payload:`, event.payload);
    },
};

Here is what I want to do with mixins:

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

function producesCustomEvent1<TBase extends Constructor<BaseClass>>(Base: TBase) {
    return class extends Base {
        emitCustomEvent1(event: CustomEvent1) {
            this.emit(event);
        }
    };
}

function producesCustomEvent2<TBase extends Constructor<BaseClass>>(Base: TBase) {
    return class extends Base {
        emitCustomEvent2(event: CustomEvent2) {
            this.emit(event);
        }
    };
}

class CustomEventEmitter extends producesCustomEvent1(producesCustomEvent2(BaseClass)) {
    constructor(eventEmitter: EventEmitter) {
        super(eventEmitter);
    }
}


const eventEmitter: EventEmitter = {
    emit(event: BaseEvent) {
        console.log(`Event type: ${event.type}, payload:`, event.payload);
    },
};

const customEventEmitter = new CustomEventEmitter(eventEmitter);

const event1: CustomEvent1 = { type: 'custom1', payload: { message: 'Hello, world!' } };
customEventEmitter.emitCustomEvent1(event1); // Output: "Event type: custom1, payload: { message: 'Hello, world!' }"

const event2: CustomEvent2 = { type: 'custom2', payload: { value: 42 } };
customEventEmitter.emitCustomEvent2(event2); // Output: "Event type: custom2, payload: { value: 42 }"

What is the problem?

When I do it like this I have two problems.

  1. The biggest problem: Every time I create a new event type I need to implement the mixins as well, which are 99% identical to all other mixins. I want to have that automated. Ideally by calling something like createEventMixin<CustomEvent1>() which creates the emitCustomEvent1 method. Is that possible?
  2. Writing something like producesCustomEvent1(producesCustomEvent2(BaseClass)) is not really readable when I add more and more events to the class. And this is the reason to not use generic in the first place, because there will be instances where there will be a lot of different produced events. Is there a way to have something like a type builder of sorts? So something like const CustomEventEmitter = Builder(BaseClass).withProducingEvent1().withProducingEvent2().return().

Solution

  • What the OP tries to achieve by ...

    const customEventEmitter = new CustomEventEmitter(eventEmitter);
    
    const event1: CustomEvent1 = { type: 'custom1', payload: { message: 'Hello, world!' } };
    customEventEmitter.emitCustomEvent1(event1); // Output: "Event type: custom1, payload: { message: 'Hello, world!' }"
    
    const event2: CustomEvent2 = { type: 'custom2', payload: { value: 42 } };
    customEventEmitter.emitCustomEvent2(event2); // Output: "Event type: custom2, payload: { value: 42 }"
    

    ... where the above code introduces differently named emit methods like emitCustomEvent1 and emitCustomEvent2, which both serve the purpose of emitting each a different (type of) event through one and the same custom emitter, gets covered by an EventTarget's dispatchEvent method where the event type is directly addressed either by a type-specific string or via the type attribute of an event-like object. Type safety (no chance of event spoofing) of the latter gets achieved by encapsulating (and later reading from) an initial event which gets created with every newly added event listener.

    Browsers and Node.js already do support EventTarget and therefore do feature such a "Signals and Slots" system. But in order to achieve the additional tasks of the OP which are ...

    • defining/registering event-type specific base-data,
    • tracing of any dispatched event's type, payload, target and consumer,

    ... one needs to implement such a system oneself. On top of that one then can implement the features the OP is looking for.

    The above linked JavaScript variant of a function based EventTargetMixin implementation needs to be altered towards featuring two more methods ... putBaseEventData and deleteBaseEventData ... in addition to the already existing ... dispatchEvent, hasEventListener, addEventListener and removeEventListener.

    The putBaseEventData method is the pendant to what the OP tries to achieve with creating differently named custom dispatch methods for and with predefined (and differently named) custom events.

    And the traceability feature gets achieved by something as simple as making the mixin being aware of accepting a tracer method at the mixin's apply time. This tracer internally gets passed to any newly added event handler (the addEventListener method has to be adapted accordingly) in order to additionally enable a handler's handleEvent method of passing all data of interest into the tracer.

    Note

    In case the following provided traceable and "putable" event-target approach would solve the OP's problem, one needs to rewrite the JavaScript mixin into a TypeScript class in order to make own custom types/classes extend from it.

    // - tracer function ...
    //   ...could be later renamed to `collectAllDispatchedEventData`
    function traceAnyDispatchedEventData({ type, baseData, event, consumer }) {
      console.log({ type, baseData, event, consumer });
    }
    
    class ObservableTraceableType {
      constructor(name) {
        this.name = name;
        TraceablePutableEventTargetMixin.call(this, traceAnyDispatchedEventData);
      }
    }
    // // for TypeScript ...
    // class ObservableTraceableType extends TraceablePutableEventTarget { /* ... */ }
    // // ... with a class based `TraceablePutableEventTarget` implementation.
    
    const a = new ObservableTraceableType('A');
    const b = new ObservableTraceableType('B');
    const c = new ObservableTraceableType('C');
    
    
    function cosumingHandlerX(/*evt*/) { /* do something with `evt` */}
    function cosumingHandlerY(/*evt*/) { /* do something with `evt` */}
    
    a.putBaseEventData('payload-with-value', { payload: { value: 1234 } });
    a.putBaseEventData('payload-with-message', { payload: { message: 'missing' } });
    
    a.addEventListener('payload-with-value', cosumingHandlerX);
    a.addEventListener('payload-with-message', cosumingHandlerX);
    
    a.addEventListener('payload-with-value', cosumingHandlerY);
    
    a.dispatchEvent('payload-with-value');
    a.dispatchEvent({
      type: 'payload-with-message',
      payload: { message: 'legally altered payload default message' },
    });
    
    
    function cosumingHandlerQ(/*evt*/) { /* do something with `evt` */}
    function cosumingHandlerR(/*evt*/) { /* do something with `evt` */}
    
    b.putBaseEventData('payload-with-value', { payload: { value: 5678 } });
    b.putBaseEventData('payload-with-message', { payload: { message: 'default message' } });
    
    b.addEventListener('payload-with-message', cosumingHandlerQ);
    b.addEventListener('payload-with-value', cosumingHandlerQ);
    
    b.addEventListener('payload-with-value', cosumingHandlerR);
    
    b.dispatchEvent('payload-with-message');
    b.dispatchEvent({
      type: 'payload-with-value',
    
      id: 'spoof-attempt-for_event-id',
      target: { spoof: 'attempt-for_event-target' },
      
      payload: { value: 9876, message: 'legally altered payload default message' },
    });
    
    
    function cosumingHandlerK(/*evt*/) { /* do something with `evt` */}
    function cosumingHandlerL(/*evt*/) { /* do something with `evt` */}
    
    c.addEventListener('non-prestored-event-data-FF', cosumingHandlerK);
    
    c.addEventListener('non-prestored-event-data-FF', cosumingHandlerL);
    c.addEventListener('non-prestored-event-data-GG', cosumingHandlerL);
    
    c.dispatchEvent('non-prestored-event-data-FF');
    c.dispatchEvent({
      type: 'non-prestored-event-data-GG',
      foo: 'FOO',
      bar: { baz: 'BAZ' },
    });
    .as-console-wrapper { min-height: 100%!important; top: 0; }
    <script>
    
    // import `TraceablePutableEventTargetMixin` from module.
    const TraceablePutableEventTargetMixin = (function () {
    
      // implementation / module scope.
    
      function isString(value/*:{any}*/)/*:{boolean}*/ {
        return (/^\[object\s+String\]$/)
          .test(Object.prototype.toString.call(value));
      }
      function isFunction(value/*:{any}*/)/*:{boolean}*/ {
        return (
          ('function' === typeof value) &&
          ('function' === typeof value.call) &&
          ('function' === typeof value.apply)
        );
      }
    
      // either `uuid` as of e.g. Robert Kieffer's
      // ... [https://github.com/broofa/node-uuid]
      // or ... Jed Schmidt's [https://gist.github.com/jed/982883]
      function uuid(value)/*:{string}*/ {
        return value
          ? (value^Math.random() * 16 >> value / 4).toString(16)
          : ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, uuid);
      }
    
      function getSanitizedObject(value/*:{any}*/)/*:{Object}*/ {
        return ('object' === typeof value) && value || {};
      }
    
      class Event {
        constructor({
          id/*:{string}*/ = uuid(),
          type/*:{string}*/,
          target/*:{Object}*/,
          ...additionalData/*:{Object}*/
        }) {
          Object.assign(this, {
            id,
            type,
            target,
            ...additionalData
          });
        }
      }
    
      class TracingEventListener {
        constructor(
          target/*:{Object}*/,
          type/*:{string}*/,
          handler/*:{Function}*/,
          tracer/*:{Function}*/,
        ) {
          const initialEvent/*:{Event}*/ = new Event({ target, type });
    
          function handleEvent(evt/*:{Object|string}*/, baseData/*:{Object}*/)/*:{void}*/ {
            const {
              id/*:{string|undefined}*/,
              type/*:{string|undefined}*/,
              target/*:{string|undefined}*/,
              ...allowedOverwriteData/*:{Object}*/
            } = getSanitizedObject(evt);
    
            // prevent spoofing of any trusted `initialEvent` data.
            const trustedEvent/*:{Event}*/ = new Event(
              Object.assign(
                {}, getSanitizedObject(baseData), initialEvent, allowedOverwriteData
              )
            );
    
            // handle event non blocking 
            setTimeout(handler, 0, trustedEvent);
    
            // trace event non blocking 
            setTimeout(tracer, 0, {
              type: trustedEvent.type, baseData, event: trustedEvent, consumer: handler,
            });
          };
          function getHandler()/*:{Function}*/ {
            return handler;
          };
          function getType()/*:{string}*/ {
            return type;
          };
    
          Object.assign(this, {
            handleEvent,
            getHandler,
            getType,
          });
        }
      }
    
      function TraceablePutableEventTargetMixin(tracer/*{Function}*/) {
        if (!isFunction(tracer)) {
          tracer = _=>_;
        }
        const observableTarget/*:{Object}*/ = this;
    
        const listenersRegistry/*:{Map}*/ = new Map;
        const eventDataRegistry/*:{Map}*/ = new Map;
    
        function putBaseEventData(
          type/*:{string}*/, { type: ignoredType/*:{any}*/, ...baseData/*:{Object}*/ }
        )/*:{void}*/ {
    
          if (isString(type)) {
            eventDataRegistry.set(type, baseData);
          }
        }
        function deleteBaseEventData(type/*:{string}*/)/*:{boolean}*/ {
          let result = false;
          if (isString(type)) {
            result = eventDataRegistry.delete(type);
          }
          return result;
        }
    
        function addEventListener(
          type/*:{string}*/, handler/*:{Function}*/,
        )/*:{TracingEventListener|undefined}*/ {
    
          let reference/*:{TracingEventListener|undefined}*/;
    
          if (isString(type) && isFunction(handler)) {
            const listeners/*:{Array}*/ = listenersRegistry.get(type) ?? [];
    
            reference = listeners
              .find(listener => listener.getHandler() === handler);
    
            if (!reference) {
              reference = new TracingEventListener(
                observableTarget, type, handler, tracer
              );
              if (listeners.push(reference) === 1) {
    
                listenersRegistry.set(type, listeners);
              }          
            }
          }
          return reference;
        }
    
        function removeEventListener(
          type/*:{string}*/, handler/*:{Function}*/,
        )/*:{boolean}*/ {
    
          let successfully = false;
    
          const listeners/*:{Array}*/ = listenersRegistry.get(type) ?? [];
          const idx/*:{number}*/ = listeners
            .findIndex(listener => listener.getHandler() === handler);
    
          if (idx >= 0) {
            listeners.splice(idx, 1);
            successfully = true;
          }
          return successfully;
        }
    
        function dispatchEvent(evt/*:{Object|string}*/ = {})/*:{boolean}*/ {
          const type = (
            (evt && ('object' === typeof evt) && isString(evt.type) && evt.type) ||
            (isString(evt) ? evt : null)
          );
          const listeners/*:{Array}*/ = listenersRegistry.get(type) ?? [];
          const baseData/*:{Object}*/ = eventDataRegistry.get(type) ?? {};
    
          listeners
            .forEach(({ handleEvent }) => handleEvent(evt, baseData));
    
          // success state      
          return (listeners.length >= 1);
        }
    
        function hasEventListener(type/*:{string}*/, handler/*:{Function}*/)/*:{boolean}*/ {
          return !!(
            listenersRegistry.get(type) ?? []
          )
          .find(listener => listener.getHandler() === handler);
        }
    
        Object.defineProperties(observableTarget, {
          putBaseEventData: {
            value: putBaseEventData,
          },
          deleteBaseEventData: {
            value: deleteBaseEventData,
          },
          addEventListener: {
            value: addEventListener,
          },
          removeEventListener: {
            value: function (
              typeOrListener/*:{TracingEventListener|string}*/,
              handler/*:{Function}*/,
            )/*:{boolean}*/ {
              return (
    
                isString(typeOrListener) &&
                isFunction(handler) &&
                removeEventListener(typeOrListener, handler)
    
              ) || (
    
                (typeOrListener instanceof TracingEventListener) &&
                removeEventListener(typeOrListener.getType(), typeOrListener.getHandler())
    
              ) || false;
            },
          },
          hasEventListener: {
            value: function (
              typeOrListener/*:{TracingEventListener|string}*/,
              handler/*:{Function}*/,
            )/*:{boolean}*/ {
              return (
    
                isString(typeOrListener) &&
                isFunction(handler) &&
                hasEventListener(typeOrListener, handler)
    
              ) || (
    
                (typeOrListener instanceof TracingEventListener) &&
                hasEventListener(typeOrListener.getType(), typeOrListener.getHandler())
    
              ) || false;
            },
          },
          dispatchEvent: {
            value: dispatchEvent,
          },
        });
    
        // return observable target/type.
        return observableTarget;
      }
    
      // module's default export.
      return TraceablePutableEventTargetMixin;
    
    }());
    
    </script>

    Note: