typescripttypesmixins

Calling a constructor class with a mixin doesn't recognize constructor Arguments in typescript


I am attempting to understand why the following code does not work.

I have a mixin that produces an anonymous class. The resulting class has a constructor function with a defined list of arguments. However, when I try to create an instance of that class, TypeScript returns an error: Expected 0 arguments, but got 1.

Of course, I can define a constructor in the TestCase class, which resolves the issue. However, I would like to know why this code does not work as expected and if there is a way to make it work without needing to add a constructor to every extended class.

type Constructor<T = {}> = new (...args: any[]) => T;

function mixin<T extends Constructor>(Base: T) {
  return class extends Base {
    constructor(...args: any[]) {
      super(...args);
    }
  };
}

@mixin
class TestCase {
}

new TestCase(1) // <- Expected 0 arguments, but got 1.

Playground


Solution

  • You can't use a decorator to change the apparent type of a class in TypeScript. This is currently true for both the new ECMAScript decorators released with TypeScript 5.0 and the older experimental TypeScript decorators.

    That means if your decorator is a mixin and it adds a property or changes the constructor parameters, that change will not be visible to TypeScript (even though it will of course happen at runtime):

    function mixin<C extends new (...args: any) => any>(Base: C) {
      return class extends Base {
        addedProp = "abc"; // adds a property
        constructor(...args: any) {
          super(...args);
        }
      };
    }
    
    @mixin class TestCase {
      constructor(public prop: number) { }
    }
    const testCase = new TestCase(1); // okay
    console.log(testCase.prop.toFixed(2)); // "1.00"
    console.log(testCase.addedProp.toString()); // "ABC" error!
    //                   ~~~~~~~~~
    // Property 'addedProp' does not exist on type 'TestCase'.
    

    So you shouldn't use decorators as mixins, unless the mixin isn't supposed to change the type of the class constructor. This is a documented limitation of decorators as mixins.There is a longstanding open feature request at microsoft/TypeScript#4881 to support mutating the type of a class, but it has yet to be implemented as of TypeScript 5.6.

    Instead of using a mixin as a decorator, you can still just use it as a regular function call:

    const TestCase = mixin(
      class TestCase {
        constructor(public prop: number) { }
      }
    );
    
    const testCase = new TestCase(1); // okay
    console.log(testCase.prop.toFixed(2)) // "1.00"
    console.log(testCase.addedProp.toUpperCase()); // "ABC"
    

    Unfortunately another issue with mixins is that you can't easily write them to change the constructor parameters. There is an open feature request at microsoft/TypeScript#37143 to allow this sort of thing, but it's not implemented.

    Presumably the only reason you'd want new TestCase(1) to succeed when class TestCase {} has a zero-arg constructor is because the mixin uses that 1 in an added property. (I've asked several times why one would want to pass 1 as an argument to the super constructor if it doesn't expect such an argument and I've yet to hear a cogent reply. So I will assume the mixin uses the first argument and passes the rest of them along).

    But due to the lack of microsoft/TypeScript#37143, the obvious implementation gives an error:

    function mixin<C extends new (...args: any) => any>(Base: C) {
      return class extends Base { // error!
        //   ~~~~~ error! A mixin class must have a constructor 
        //  with a single rest parameter of type 'any[]'.(2545)
        constructor(public addedProp: string, ...args: ConstructorParameters<C>) {
          super(...args);
        }
      }
    }
    

    So if you want this to happen, you'll need to take a lot more direct control over the types. You'll have to write out what you expect the mixin to do, explictly, and use lots of type assertions to quell compiler errors:

    function mixin<A extends any[], T extends object>(Base: new (...args: A) => T) {
      return class extends (Base as any) {
        constructor(public addedProp: string, ...args: A) {
          super(...args);
        }
      } as new (addedProp: string, ...args: A) => (T & { addedProp: string })
    }
    

    Now when you call it, the returned constructor type prepends the new argument to the list the super constructor expected, and returns an instance type with the added property:

    const TestCase = mixin(
      class TestCase {
        constructor(public prop: number) { }
      }
    );
    /* const TestCase: new (addedProp: string, prop: number) => TestCase & {
      addedProp: string;
    } */
    
    const testCase = new TestCase("abc", 1);
    console.log(testCase.prop.toFixed(2)) // "1.00"
    console.log(testCase.addedProp.toUpperCase()) // "ABC"
    

    So right now that's how one might proceed until the above feature requests are implemented, if ever: use mixins as function calls and not as decorators, and be prepared to use a lot of type juggling in the mixin call signature to have it behave as desired.

    Playground link to code