Reputation: 3797
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
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