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.
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.