typescriptooptypesprototypewrapper

type safe wrapper class in typescript


I'm trying to create a wrapper class. I have a Base class from an imported package and want to wrap it with some logging and try/catch, in such a way that the Wrapper class is fully abstracted away from the user, and can be used as if using the Base class. What I have so far:

class Base {
    name: string;

    constructor(name: string) {
        this.name = name;
    }

    first() {}
    second(val: number) {}
    third(vals: number[]) {}
    ... // a lot of methods, can't map them manually
}

class Wrapper {
    bases: Base[] = [];

    constructor(names: string[]) {
        for (const name of names) {
            this.bases.push(new Base(name));
        }
    }
}

Object.getOwnPropertyNames(Base.prototype).forEach((methodName) => {
    if (methodName !== 'constructor') {
        Wrapper.prototype[methodName] = function (...args) {
            for (const base of this.bases) {
                try {
                    // logging 
                    return base[methodName].apply(base, args);
                } catch {}
            }
        };
    }
});

The problem with this is that I get some type warnings:

and ignoring those, the wrapper class doesn't inherit the Base type, so that if I do

const wrapper = new Wrapper(['a', 'b', 'c']);
const s = wrapper.first();

I get Property 'first' does not exist on type 'Wrapper'.

Is there a better way to ensure type safety besides overriding manually the type as unknown as Base? I could extend the Base class though I would miss some class properties, such as name in this example.

EDIT: added some more examples of method in Base class. The Base class is quite large, so there are lot of different methods, including with optional parameters, overloads, etc


Solution

  • Assuming we care more about the experience of those using Wrapper and less about the TypeScript compiler being able to verify that we have implemented Wrapper properly, I would be inclined to proceed like this:

    First, let's determine which properties of Base we need to copy over into Wrapper. It looks like it's supposed to be all the methods. TypeScript can't really tell the difference between a method (on the prototype) and a function-valued property (on each instance) so I'm hoping you don't have any of the latter. We can use a key-remapped mapped type to filter the properties of an object to just the function properties:

    type FuncsOf<T> = {
      [K in keyof T as T[K] extends Function ? K : never]: T[K]
    }
    

    Then, we can merge FuncsOf<Base> into the Wrapper instance type:

    interface Wrapper extends FuncsOf<Base> { }
    

    This tells TypeScript that a Wrapper instance also is assignable to FuncsOf<Base>, without requiring or caring that it's actually implemented anywhere. This is a bit dangerous, since if you forgot to write your loop over Base.prototype entries, or if you wrote it incorrectly, the compiler won't know or care. But I'm not worried about that here because as I said at the top, we're focusing on the users of Wrapper and not the implementer. We'll just have to be careful with the implementation.

    And since that's the case, I'm just going to use the any type and type assertions to sidestep any compiler errors inside this loop:

    Object.getOwnPropertyNames(Base.prototype).forEach((methodName) => {
      if (methodName !== 'constructor') {
        (Wrapper.prototype as any)[methodName] = function (...args: any) {
          for (const base of this.bases) {
            try {
              // logging 
              return base[methodName].apply(base, args);
            } catch { }
          }
        };
      }
    });
    

    There are ways to begin to try to write this so the compiler checks the loop more closely, but frankly they are more trouble than they're worth. (If you're interested in going down the rabbit hole, you can look at microsoft/TypeScript#47109 to get a flavor of the kinds of typing gymnastics involved.)


    Anyway, let's try it out:

    const wrapper = new Wrapper(['a', 'b', 'c']);
    const s = wrapper.first(); // okay
    wrapper.second(2); // okay
    wrapper.third([1, 2, 3]); // okay
    

    Looks good!

    Playground link to code