Search code examples
typescripttypescript-generics

self referencing generics in the class initializer


Consider this class definition:

class MyClass<T extends Record<string, unknown> = any> {
  constructor(
    public params: {
      methodOne: (data: Record<string, unknown>) => T;
      methodTwo: (data: T) => void;
    }
  ) {
    // ...
  }
}
// OK
const myClassInstance = new MyClass({
  methodOne: (data) => {
    return {
      name: "foo",
      lastName: "bar",
    };
  },
  methodTwo: (data) => {
    console.log(data.lastName); //ok
    console.log(data.name); //ok
  },
})

So what I'm trying to do is infer the data parameter in the methodTwo (which comes from the methodOne and this works correctly in this kind of setup, when arguments are created inline when instantiating the class instance.

Now I wanna do the same thing but instead of declaring the methodTwo directly in the params argument to the MyClass instance I want to have it somewhere else in the code, and this is where I get the dreaded (7022) error

// NOT OK
//'myClassInstanceTwo' implicitly has type 'any' because it does not have a type annotation 
//and is referenced directly or indirectly in its own initializer.(7022)
const myClassInstanceTwo = new MyClass({
  methodOne: (data) => {
    return {
      name: "foo",
      lastName: "bar",
    };
  },
  methodTwo: methodTwoExternal,
});

type MethodOneReturn<T extends MyClass> = ReturnType<T["params"]["methodOne"]>;
// 'data' is referenced directly or indirectly in its own type annotation.(2502)
function methodTwoExternal(data: MethodOneReturn<typeof myClassInstanceTwo>) {
  console.log(data.lastName); //ok
  console.log(data.name); //ok
}

Is there a way to make this work? I'm open to changing the MyClass constructor signature, maybe even introduce a factory function for the class instantiation.

TS Playground


Solution

  • All I can think of here (assuming this pattern is something we need to support) would be to make some sort of builder that lets you set methodOne first, then get an intermediate thing that lets you extract the type T from it, and then lets you set methodTwo and finally gives you a MyClass<T> instance. Something like:

    class MyClass<T extends Record<string, unknown> = any> {    
      static builder = {
        m1<T extends Record<string, unknown>>(
          methodOne: (data: Record<string, unknown>) => T) {
          return {
            __type: null! as T,
            m2(methodTwo: (data: T) => void) {
              return new MyClass({ methodOne, methodTwo })
            }
          }
        }
      }
      constructor(public params: {
        methodOne: (data: Record<string, unknown>) => T
        methodTwo: (data: T) => void
      }) { }    
    }
    

    So MyClass.builder.m1(method1).m2(method2) is the same as new MyClass(method1, method2), but now you can pause at MyClass.builder.m1(method1) and inspect it for the type you care about. To make that easy I added a dummy/phantom __type property, so that the type is like typeof xxx.__type instead of something awful like Parameters<Parameters<typeof xxx.m2>[0]>[0].


    Let's see if it works:

    const myClassInstanceTwoBuilder =
      MyClass.builder.m1((data) => {
        return {
          name: 'foo',
          lastName: 'bar'
        }
      });
    
    function methodTwoExternal(data: typeof myClassInstanceTwoBuilder.__type) {
      console.log(data.lastName) //ok
      console.log(data.name) //ok
    }
    
    const myClassInstanceTwo =
      myClassInstanceTwoBuilder.m2(methodTwoExternal);
    

    Looks good.

    Playground link to code