Typescript: Add support for events as class members.

Created on 4 Feb 2019  路  7Comments  路  Source: microsoft/TypeScript

Search Terms

event, delegate.

Suggestion

Add the keyword "event", and make the event an explicit member of the class. Thus, you can check the event listeners, the correctness of the arguments when emitting events and etc.

Use Cases

Problems

  • There is no validation check of the event name when emitting, adding and deleting a listeners
  • There is no check of sent arguments when emitting an event
  • No validation of listeners arguments
  • Documentation generators cannot pull the list of class events
  • You cannot inherit EventEmitter methods if my class must inherit from another class that is not ancestor of EventEmitter
  • If you need a reaction to add and remove listeners, you have to write one big function in which the necessary events are filtered
  • If you build events on decorators or getters, then a large number of objects are created that are not used

Examples

"event" keyword full semantic

class ClassWithEvents {
    //declare a simple event without arguments
    public event simpleEvent;

    //declare a simple event with arguments
    public event simpleEventWithArguments(arg: Arg1Type);

    //declare a static event with reaction and arguments
    public static event staticEventWithReaction(arg1: Arg1Type, arg2: Arg2Type) {
        //The first argument of the listeners is always the sender of the event
        add(listener: (sender: this, arg1: Arg1Type, arg2: Arg2Type) => void) {
            //Do something when adding a listener
        }
        remove(listener: (sender: this, arg1: Arg1Type, arg2: Arg2Type) => void) {
            //Do something when removing a listener
        }
    }

    static emitEvent() {
        //The emitter sends only arguments
        //the "sender" parameter is sent by the system automatically.
        this.staticEventWithReaction(new Arg1Type , new Arg2Type);
    }
}

//Event interface
interface Event<SenderT, Arg1T, Arg2T/*etc*/> {
    //Method adds/remove a listener
    on(listener: (sender: SenderT, arg1: Arg1T, arg2: Arg2T) => void);
    once(listener: (sender: SenderT, arg1: Arg1T, arg2: Arg2T) => void);
    remove(listener: (sender: SenderT, arg1: Arg1T, arg2: Arg2T) => void);

    //Method adds/remove a listener and binds context to it
    on(context: any, listener: (sender: SenderT, arg1: Arg1T, arg2: Arg2T) => void);
    once(context: any, listener: (sender: SenderT, arg1: Arg1T, arg2: Arg2T) => void);

    //Method removes all event listeners
    removeAll();

    //Method emit event
    emit(arg1: Arg1T, arg2: Arg2T);

    //Method removes all listeners to all events
    static removeAll(sender: SenderT);
}


//Use events
const myContext = {};
const listener = (sender: ClassWithEvents, arg1: Arg1Type, arg2: Arg2Type) => {

};

//add/remove listener
ClassWithEvents.staticEventWithReaction.on(listener);
ClassWithEvents.staticEventWithReaction.remove(listener);

//add/remove listener with manual context binding
const listenerWithContext = listener.bind(myContext);
ClassWithEvents.staticEventWithReaction.on(listenerWithContext);
ClassWithEvents.staticEventWithReaction.remove(listenerWithContext);

//add listener with auto context binding
ClassWithEvents.staticEventWithReaction.on(myContext, listener);

//removes all event listeners
ClassWithEvents.staticEventWithReaction.removeAll();

//removes all listeners to all events
Event.removeAll(ClassWithEvents);

Tslib helpers

function __eventAddListener(target, eventName, listener, context, once) {
    if( typeof context !== "undefined" ) {
        listener = listener.bind(context);
    }

    var events = target['__tsevents'];
    if( !events ) {
        events = target['__tsevents'] = {};
    }

    var event = events[eventName];
    if( !event ) {
        event = events[eventName] = [];
    }

    event.push({
        listener: listener,
        once: once || false
    });

    __eventAddListenerTrigger(target, eventName, listener);
}

function __eventRemoveListener(target, eventName?, listener?) {
    var events = target['__tsevents'];
    if( !events ) {
        return;
    }
    else if( typeof eventName === "undefined" ) {
        __eventRemoveListenerTrigger(target);
        target['__tsevents'] = null;
        return;
    }

    var event = events[eventName];
    if( !event ) {
        return;
    }
    else if( typeof listener === "undefined" ) {
        __eventRemoveListenerTrigger(target, eventName);
        events[eventName] = null;
        return;
    }

    events[eventName] = event.filter(function(event) {
        var isEquals = event.listener === listener;
        __eventRemoveListenerTrigger(target, eventName, listener);
        return !isEquals;
    });
}

function __eventDeclareTrigger(target, eventName, trigger) {
    var triggers = target['__tseventstriggers'];
    if( !triggers ) {
        triggers = target['__tseventstriggers'] = {};
    }

    triggers[eventName] = trigger;
}

function __eventRemoveListenerTrigger(target, eventName?, listener?) {
    var triggers = target['__tseventstriggers'];
    if( !triggers ) {
        return;
    }
    else if( typeof eventName === "undefined" ) {
        Object.keys(triggers).forEach(eventName => {
            if( target['__tsevents'] && target['__tsevents'][eventName] ) {
                target['__tsevents'][eventName].forEach(event => {
                    triggers[eventName].remove.call(target, event.listener)
                });
            }
        });
        return;
    }

    var trigger = triggers[eventName];
    if( !triggers ) {
        return;
    }
    else if( typeof listener === "undefined" ) {
        if( target['__tsevents'] && target['__tsevents'][eventName] ) {
            target['__tsevents'][eventName].forEach(event => {
                trigger.remove.call(target, event.listener)
            });
        }
        return;
    }

    if( target['__tsevents'] && target['__tsevents'][eventName] ) {
        trigger.remove.call(target, listener);
    }
}

function __eventAddListenerTrigger(target, eventName, listener) {
    var triggers = target['__tseventstriggers'];
    if( !triggers ) {
        return;
    }

    var trigger = triggers[eventName];
    if( !triggers ) {
        return;
    }

    trigger.add.call(target, listener);
}

function __eventEmit(sender, eventName, ...args) {
    var events = sender['__tsevents'];
    if( !events ) {
        return;
    }

    var event = events[eventName];
    if( !event ) {
        return;
    }

    event.forEach(event => {
        event.listener(sender, ...args);
        if( event.once ) {
            __eventRemoveListener(sender, eventName, event.listener);
        }
    })
}

Compile result

var ClassWithEvents = (function () {
    function ClassWithEvents() {
    }
    ClassWithEvents.emitEvent = function () {
        //The emiter sends only arguments
        //the "sender" parameter is sent by the system automatically.
        __eventEmit(this, 'staticEventWithReaction', new Arg1Type, new Arg2Type);
    };

    __eventDeclareTrigger(ClassWithEvents, 'staticEventWithReaction', {
        add: function add() {
            //Do something when adding a listener
        },
        remove: function remove() {
            //Do something when removing a listener
        }
    })
    return ClassWithEvents;
}());
//Use events
var myContext = {};
var listener = function (sender, arg1, arg2) {
};
//add/remove listener
__eventAddListener(ClassWithEvents, 'staticEventWithReaction', listener);
__eventRemoveListener(ClassWithEvents, 'staticEventWithReaction', listener);
//add/remove listener with manual context binding
var listenerWithContext = listener.bind(myContext);
__eventAddListener(ClassWithEvents, 'staticEventWithReaction', listenerWithContext);
__eventRemoveListener(ClassWithEvents, 'staticEventWithReaction', listenerWithContext);
//add listener with auto context binding
__eventAddListener(ClassWithEvents, 'staticEventWithReaction', listener, myContext);
//removes all event listeners
__eventRemoveListener(ClassWithEvents, 'staticEventWithReaction');
//removes all listeners to all events
__eventRemoveListener(ClassWithEvents);

Checklist

My suggestion meets these guidelines:

  • [ x ] This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • [ x ] This wouldn't change the runtime behavior of existing JavaScript code
  • [ x ] This could be implemented without emitting different JS based on the types of the expressions
  • [ x ] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • [ x ] This feature would agree with the rest of TypeScript's Design Goals.
Out of Scope Suggestion

Most helpful comment

@dragomirtitian I think I'll be older by the time they include events in ECMAScript. And there they do not make much sense, just to explicitly define events. And in typescript it is very necessary. All the same, there is hope.

All 7 comments

I don't think this meets the guideline that:

This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)

The event syntax is 'non-ECMAScript syntax' and it does have output in JavaScript. Such features are usually implemented only if they are part of a future ECMAScript standard (at stage 3) and have to just be poly-filled for lower target versions. If event is used in a future in the standard it might break the 'Typescript is a superset of JavaScript` goal.

That being said, there is a case to be made to include this in the language, but I think you might have to get it included in JS first :)

@dragomirtitian I think I'll be older by the time they include events in ECMAScript. And there they do not make much sense, just to explicitly define events. And in typescript it is very necessary. All the same, there is hope.

@geverges-oleg I don't want to be a downer here, but it seems to me a lot of your static typing concerns are solvable in TS today, just buy using an object to represent the event.

This (admittedly naive) implementation would fix the issues that are directly related to types.

type ThisParameterType<T extends Function> =  T extends (this: infer U, ... a: any[]) => any ? U: object;
export class CustomEvent<TFn extends (...a: any[]) => void = () => void> {
    constructor(private addRemove?: { 
        add: (listener: TFn) => void
        remove: (listener: TFn) => void
    }){

    }
    private listeners: TFn[] = [];
    on<TActualFn extends TFn>(context: ThisParameterType<TActualFn>, fn: TActualFn) : void
    on(fn: TFn): void
    on(context: object | TFn, fn?: TFn): void{
        if(typeof context !== "function")  {
            fn = fn!.bind(context) as TFn;
        }else {
            fn = context as TFn
        }
        if(this.addRemove) this.addRemove.add(fn!);
        this.listeners.push(fn!);
    }
    emit(...args: Parameters<TFn>) {
        this.listeners.forEach(fn => fn(...args));
    }
    remove(fn: TFn){
        var index = this.listeners.indexOf(fn);
        if(index > -1){
            if(this.addRemove) this.addRemove.remove(this.listeners[index]);
            this.listeners.splice(index, 1);
        }
    }

    removeAll(){
        this.listeners = []
    }
    static removeAll(target: any) {
        Object.values(target)
            .forEach(o => {
                if(o instanceof CustomEvent) {
                    o.removeAll();
                }
            })
    }
}

class Arg1Type { a!: number }
class Arg2Type { b!: number }

class ClassWithEvents {
    //declare a simple event without arguments
    public simpleEvent = new CustomEvent();

    private _lazyEvent: CustomEvent | undefined;
    public get lazyEvent () {
        return this._lazyEvent || (this._lazyEvent = new CustomEvent());
    }

    //declare a simple event with arguments
    public simpleEventWithArguments = new CustomEvent<(sender: this, arg: Arg1Type) => void>();

    //declare a static event with reaction and arguments
    public static staticEventWithReaction =  new CustomEvent<(sender: ClassWithEvents, arg1: Arg1Type, arg2: Arg2Type) => void>({
        //The first argument of the listeners is always the sender of the event
        add(listener) {
            //Do something when adding a listener
        },
        remove(listener) {
            //Do something when removing a listener
        }
    });

    static emitEvent(sender: ClassWithEvents) {
        this.staticEventWithReaction.emit(sender, new Arg1Type , new Arg2Type);
    }
}

//Use events
const myContext = {};
const listener = (sender: ClassWithEvents, arg1: Arg1Type, arg2: Arg2Type) => {

};
const badListener = (sender: ClassWithEvents, arg1: Arg2Type, arg2: Arg1Type) => {
}

//add/remove listener
ClassWithEvents.staticEventWithReaction.on(listener);
ClassWithEvents.staticEventWithReaction.on(badListener); // err
ClassWithEvents.staticEventWithReaction.remove(listener);

//add/remove listener with manual context binding
const listenerWithContext = listener.bind(myContext);
ClassWithEvents.staticEventWithReaction.on(listenerWithContext);
ClassWithEvents.staticEventWithReaction.remove(listenerWithContext);

//add listener with auto context binding
ClassWithEvents.staticEventWithReaction.on(myContext, listener);

//removes all event listeners
ClassWithEvents.staticEventWithReaction.removeAll();

ClassWithEvents.staticEventWithReaction.emit(new ClassWithEvents, new Arg1Type, new Arg2Type) // ok
ClassWithEvents.staticEventWithReaction.emit(new ClassWithEvents, new Arg1Type) // err emit args not ok 

//removes all listeners to all events
CustomEvent.removeAll(ClassWithEvents);

Playground link

I'm not sure I got the semantics of your static events 100% right, but regardless of implementation details which could be changed and optimized, this would fix most of your problmes

  • [X] There is no validation check of the event name when emitting, adding and deleting a listeners - Events are fields, there is no name to be specified.
  • [X] There is no check of sent arguments when emitting an event - They are statically checked
  • [X] No validation of listeners arguments - They are statically checked
  • [X] Documentation generators cannot pull the list of class events - Partially, you have a list of class members that are of an event type, which should make it clear they are events.
  • [X] You cannot inherit EventEmitter methods if my class must inherit from another class that is not ancestor of EventEmitter - No event emitter,
  • [X] If you need a reaction to add and remove listeners, you have to write one big function in which the necessary events are filtered - The add/remove can be arguments to the event constructor.
  • [ ] If you build events on decorators or getters, then a large number of objects are created that are not used - You indeed get some extra objects, but you can use a lazy property if this a concern.

Added to this is the huge advantage of not having the language burdened with a non standard extension to JS that could potentially break in the future. Also gives flexibility of implementation to the developer.

@dragomirtitian covered all the relevant points here. This is the kind of feature we're actively avoiding implementing.

If you build events on decorators or getters, then a large number of objects are created that are not used

@dragomirtitian In this problem, I just meant what you offer. Lazy property doesn鈥檛 help much here, as many objects will be created during emitting of the event.
I agree, this approach helps to solve all other problems with type checking, but it creates a problem with performance and memory consumption. For high-load systems, this is unacceptable, so you have to use EventEmitter. If you make events at the compiler level, it will be perfect.

@dragomirtitian covered all the relevant points here. This is the kind of feature we're actively avoiding implementing.

@RyanCavanaugh Very sad.
You reject a lot of useful offers, either for reasons that this is not ECMAScript standards, or because it allegedly can spoil something. ECMAScript is developing very slowly, so typescript was like a breath of clean air and hope for convenient development tools. But lately, typescript has become more and more frustrating, with every release there are less and less really useful and necessary features.

Can you think about a separate compiler branch? Extended Typescript, which will include interesting and experimental features that are not in the ECMAScript standard, a huge number of developers will thank you for this.

TypeScript is Apache-licensed; no one will stop you.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

zhuravlikjb picture zhuravlikjb  路  3Comments

MartynasZilinskas picture MartynasZilinskas  路  3Comments

bgrieder picture bgrieder  路  3Comments

blendsdk picture blendsdk  路  3Comments

wmaurer picture wmaurer  路  3Comments