Search code examples
typescripttypeselectron

How to copy class methods bound to an instance to a plain object in typescript?


I am writing a preload script for Electron and in the preload script we are importing a class, creating an instance and wanting to expose it in the render. Based on the Electron docs we cannot copy a class over the context bridge.

As a result, I created an object with keys that match the method names of the class, with values as class functions bound to an instance of the class like so:

// What I am given
class Calculator {
  add(a: number, b: number) { return a + b }
}

const instance = new Calculator();

// What I am trying to create programmatically
const calcObj = {
  add: Calculator.prototype.add.bind(instance)
}

The issue is the real classes I want to expose have many methods and there are many of the classes so I hope that I can create calcObj programmatically and keep the typescript types

While trying to do this programmatically this is the "best" thing I could come up with:

class Calculator {
  add(a: number, b: number) {
    return a + b;
  }
  subtract(a: number, b: number) {
    return a - b;
  }
  multiply(a: number, b: number) {
    return a * b;
  }
  divide(a: number, b: number) {
    return a / b;
  }
  stringify() {
    return this.randomValue.toString();
  }

  randomValue = 0;
}

// Incorrect type, as it includes class variables (randomValue). Willing to live with this though
type ResultObject = {
  [K in keyof Calculator]: Calculator[K];
};

const instance = new Calculator();

// Maybe there is a better way to get class function names in a list?
const protoKeys = Object.getOwnPropertyNames(Calculator.prototype) as (keyof Calculator)[];

export const objCapture = protoKeys.reduce((acc, key) => {
  const fn = Calculator.prototype[key];
  if (typeof fn !== 'function') {
    return acc;
  }
  console.log('Copying function', key);
  // Typescript ERROR here, the result of fn.bind can be any 
  // of the function types on the class, which cannot be assigned to any of the acc[key]
  acc[key] = fn.bind(instance);
  return acc;
}, {} as ResultObject);

console.log(objCapture);

With this implementation I get a type error doing acc[key] = fn.bind(instance) where fn is Calculator.prototype[key] because it does not know which function fn is (can be any of add, stringify etc.) and it doesn't know which key of acc we are using. Additionally, my ResultObject technically isn't accurate either because it includes class variables (randomValue for example).

Is there a better way to create an object bound to a class instance like


Solution

  • TypeScript cannot distinguish at the type level whether or not a property is "own" or inherited. So when you look at Calculator.prototype, TypeScript just gives this the type Calculator, which isn't fully accurate because it includes instance properties like randomValue(). But whenever this is brought up (e.g., microsoft/TypeScript#55904) the TS team usually just says it is what it is, and it's probably not going to change.

    If you assume that all non-function properties of a class instance belong to the instance directly, and that all function properties of a class are methods that belong to the prototype, then you can use key remapping to filter out all non-function properties to get an apporximation of the prototype type:

    type ResultObject = {
      [K in keyof Calculator as Calculator[K] extends Function ? K : never]:
      Calculator[K];
    };
    /* type ResultObject = {
        add: (a: number, b: number) => number;
        subtract: (a: number, b: number) => number;
        multiply: (a: number, b: number) => number;
        divide: (a: number, b: number) => number;
        stringify: () => string;
    } */
    

    After this I think there's not much point in trying to convince TypeScript that what you're doing as safe. You're already asserting that the initial {} object for reduce() is of type ResultObject, so you might as well just assert that fn.bind(instance) is some loose type like any and just move on:

    const objCapture = protoKeys.reduce((acc, key) => {
      const fn = Calculator.prototype[key];
      if (typeof fn !== 'function') {
        return acc;
      }
      console.log('Copying function', key);
      acc[key] = fn.bind(instance) as any; // <-- might as well
      return acc;
    }, {} as ResultObject);
    

    You could try to write your reduce() callback in such a way that the compiler sees the relationship between acc[key] and fn.bind(instance), using the technique laid out in microsoft/TypeScript#47109 moving away from unions and toward generics, but it's tedious and complicated to explain:

    type ResultMethod<K extends keyof ResultObject> =
        (...args: Parameters<ResultObject[K]>) => ReturnType<ResultObject[K]>
    
    const objCapture = protoKeys.reduce(<K extends keyof ResultObject>(
        acc: { [P in K]: ResultMethod<P> }, key: K
    ) => {
        const proto: { [P in keyof ResultObject]: ResultMethod<P> } =
            Calculator.prototype;
        const fn: ResultMethod<K> = proto[key];
        if (typeof fn !== 'function') {
            return acc;
        }
        acc[key] = fn.bind(instance);
        return acc;
    }, {} as ResultObject);
    

    That works, but why bother? It seems that unless your use case is quite atypical, the diminishing returns you get from having the compiler verify that fn.bind(instance) is compatible with acc[key] are just not worth the complexity. I'm not inclined to spend several paragraphs explaining how it works here (you can read ms/TS#47109 for that) and I wouldn't expect anyone to maintain the above code instead of just using as any and being careful.

    Playground link to code