Tarlen
Tarlen

Reputation: 3797

Referncing decorated class method with this context intact

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
    }

}

Upvotes: 2

Views: 1958

Answers (1)

Estus Flask
Estus Flask

Reputation: 222503

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.

Upvotes: 3

Related Questions