2

I'm writing a short decorator helper function to turn a class into an event listener

My problem is that the decorators will register the decorated method as a callback for incoming events, but the decorated method won't retain it's original this context.

Main question how can I retain the this context of the decorated method in this scenario?

Implementation:

export function EventHandler (topicKey: any): ClassDecorator {
    return function (target: any) {
        const subscriptions = Reflect.getMetadata('subscriptions', target.prototype)

        const topic = Container.get<DomainTopicInterface>(topicKey)
        topic.subscribe(event => {
            if (subscriptions.length === 0) {
                throw new Error(`Event received for '${target.constructor.name}' but no handlers defined`)
            }
            subscriptions.forEach((subscription: any) => {
                subscription.callback(event) // <---- the this context is undefined
            })
        })

        return target
    }
}

export function Subscribe (targetClass: StaticDomainEvent<any>): MethodDecorator {
    return function (target: Function, methodName: string, descriptor: TypedPropertyDescriptor<any>) {
        let originalMethod = descriptor.value
        let subscriptions = Reflect.getMetadata('subscriptions', target)
        if (!subscriptions) { Reflect.defineMetadata('subscriptions', subscriptions = [], target) }

        subscriptions.push({
            methodName,
            targetClass,
            callback: originalMethod
        })
    }
}

Example usage:

@EventHandler(Infra.DOMAIN_TOPIC)
export class JobHandler {

    constructor (
        @Inject() private service: JobService
    ) {}

    @Subscribe(JobCreated)
    jobCreated (events: Observable<JobCreated>) {
        console.log(this) // undefined
    }

}
Tarlen
  • 3,657
  • 8
  • 30
  • 54

1 Answers1

3

The problem is that the decorator has no access to this class instance. It is evaluated only once on class definition, target is class prototype. In order to get class instance, it should decorate class method or constructor (extend a class) and get this from inside of it.

This is a special case of this problem. jobCreated is used as a callback, so it should be bound to the context. The shortest way to do this would be to define it as an arrow:

@Subscribe(JobCreated)
jobCreated = (events: Observable<JobCreated>) => {
    console.log(this) // undefined
}

However, this likely won't work, due to the fact that Subscribe decorates class prototype, while arrows are defined on class instance. In order to handle this properly, Subscribe should additionally handle properties correctly, like shown in this answer. There are some design concerns why prototype functions should be preferred over arrows, and this is one of them.

A decorator may take the responsibility to bind a method to the context. Since instance method doesn't exist at the moment when decorator is evaluated, subscription process should be postponed until it will be. Unless there are lifecycle hooks available in a class that can be patched, a class should be extended in lifecycle hook in order to augment the constructor with subscription functionality:

export function EventHandler (topicKey: any): ClassDecorator {
    return function (target: any) {
        // run only once per class
        if (Reflect.hasOwnMetadata('subscriptions', target.prototype))
            return target;

        target = class extends (target as { new(...args): any; }) {
            constructor(...args) {
                super(...args);

                const topic = Container.get<DomainTopicInterface>(topicKey)
                topic.subscribe(event => {
                    if (subscriptions.length === 0) {
                        throw new Error(`Event received for '${target.constructor.name}'`)
                    }
                    subscriptions.forEach((subscription: any) => {
                        this[subscription.methodName](event); // this is available here
                    })
                })
            }
        } as any;


export function Subscribe (targetClass: StaticDomainEvent<any>): MethodDecorator {
    return function (target: any, methodName: string, descriptor: TypedPropertyDescriptor<any>) {
        // target is class prototype
        let subscriptions = Reflect.getOwnMetadata('subscriptions', target);

        subscriptions.push({
            methodName,
            targetClass
            // no `callback` because parent method implementation
            // doesn't matter in child classes
        })
    }
}

Notice that subscription occurs after super, this allows to bind methods in original class constructor to other contexts when needed.

Reflect metadata API can also be replaced with regular properties, particularly symbols.

Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • Thanks for the detailed response. Although trying the bind-decorator solution, the `descriptor.value` returns undefined after calling bind for some reason – Tarlen May 12 '18 at 08:43
  • It's neither, but I'll try something similar, thanks for your time :) – Tarlen May 12 '18 at 15:27
  • I updated the answer. I would expect it to work in your case. Any way, if there are no lifecycle hooks in your case, subscription should take place in class constructor, i.e. a class should be extended. Notice that hasOwnMetadata is used, i.e. each class in a hierarchy gets own `subscriptions`. Since the call of `callback` from parent class in child class with child context is never desirable (the method with same name could be overridden, while this call will be similar to super[methodName]()), we should call them only by method names. – Estus Flask May 12 '18 at 16:58