Search code examples
typescriptdesign-patternsdecorator

How to use class decorator pattern if an original class method is recursive in TypeScript?


Just to clarify, I'm using decorator pattern/approach from https://refactoring.guru/design-patterns/decorator and not experimental decorators feature in TypeScript.

I'm trying to extend the next method on my main class Foo. There are several features implemented by different classes like Bar, Baz... So I thought that decorator pattern would do the trick. However the issue is that the original next method could call itself in certain conditions. And when it happens, it of course calls the original next instead of decorator next. What would be the proper approach here? Should I instead use some different pattern?

interface IFoo {
    next(): number;
}

class Foo implements IFoo {
    next(): number {
        console.log("Foo begin");
        // ...
        if (this.abc()) return this.next();
        // ...
        const result = this.xyz();
        console.log("Foo end", result);
        return result;
    }

    protected abc() {
        return Math.random() < 0.5;
    }
    protected xyz(): number {
        return Math.random();
    }
}

class FooDecorator implements IFoo {
    protected wrappee: IFoo;

    constructor(wrappee: IFoo) {
        this.wrappee = wrappee;
    }

    next() {
        return this.wrappee.next();
    }
}

class Bar extends FooDecorator {
    next() {
        console.log("Bar begin");
        const result = this.wrappee.next() + 1;
        console.log("Bar end", result);
        return result;
    }
}


let instance: IFoo = new Foo();
instance = new Bar(instance);

instance.next();

TS Playground


Solution

  • Unfortunately you hit a fundamental drawback of the "Decorator Pattern" as described in your source, but which is rarely emphasized on (same issue with Proxy): the "decoration" takes effect only when it is called from "outside". When the instance refers to itself (through this, either recursively, like in your case, or through another method), it bypasses any applied modification (decoration or proxy).

    E.g. if you had "decorated" the abc() method and called next(), the decoration would never execute because abc() is called from within the instance (inside its next() method).


    That being said, you can still achieve the objective of adding extra behavior that always takes effect (i.e. even when the method calls itself recursively, or is called by another method of the same instance): thanks to the mutability of objects in JavaScript, including their methods, it is easy to "swap" them. But we need then to manually keep a reference to the original method, because there is no "super" sugar in this case:

    const instance: IFoo = new Foo();
    
    // JavaScript object methods are mutable
    const originalNext = instance.next.bind(instance); // Keep a reference to the "super" method
    instance.next = function () {
        console.log("Custom begin");
        const result = originalNext() + 1;
        console.log("Custom end", result);
        return result;
    }
    
    instance.next();
    

    There is also a more "TypeScript way" of achieving this: using class Mixins

    function DecorateNext<BC extends GConstructor<{ next(): number }>>(BaseClass: BC) {
        return class DecoratedNext extends BaseClass {
            next() {
                console.log("Mixin begin");
                const result = super.next() + 1; // Here we can use "super" to refer to the base class behavior
                console.log("Mixin end", result);
                return result;
            }
        }
    }
    
    const instance2 = new (DecorateNext(Foo))();
    
    instance2.next();
    

    ...with GConstructor:

    // For TypeScript mixin pattern
    type Constructor = new (...args: any[]) => {};
    type GConstructor<T = {}> = new (...args: any[]) => T;
    

    Then you can apply successive Mixins to add more behavior as needed.

    Playground Link