This is quite tricky but can be done in many ways, depending on how closely you want it to resemble the behavior of e.g. the built-in Array
constructor that works like that.
Problem #1 - constructor cannot be called without 'new'
This is not specific to TypeScript, this is a problem of JavaScript.
Constructors created with the class
keyword cannot be called without new
in JavaScript:
> class A {}
undefined
> new A()
A {}
> A()
TypeError: Class constructor A cannot be invoked without 'new'
Just like arrow functions cannot be called with new
:
> B = () => {}
[Function: B]
> B()
undefined
> new B()
TypeError: B is not a constructor
Only functions created with the function
keyword can be called both with and without new
:
> function C() {}
undefined
> C()
undefined
> new C()
C {}
(What is funny is that if you transpile both arrow functions and class
keyword constructors to JS older than ES6 then all A()
, B()
and C()
above will work both with and without new
as they all will get transpiled to old style functions with the function
keyword and work just fine even on current engines.)
Problem #2 - constructor doesn't get the right 'this' without 'new'
Once you overcome the problem of errors invoking your constructor, you need to make sure that the constructor actually gets a new object.
In JavaScript (and in TypeScript) the new
keyword creates a new object and binds it to this
in the constructor, and returns it. So if you have a function:
function f() {
console.log(this);
}
then if you call it as new f()
it will print and empty object and return it.
If you call it as f()
without new
then it will print the global object (window
in browsers or global in Node or
selfin web worker - see my module on npm [the-global-object](https://www.npmjs.com/package/the-global-object) for more info) and it will return
undefined`.
Problem #3 - static types are tricky to define
This problem is TypeScript-specific. You need to make sure that all the types work as expected and they work in a useful way. It's easy to declare everything as any
but then you'll loose all of the hints in your editor and the TypeScript compiler will not detect type errors during compilation.
Problem #4 - it's easy to make a solution that doesn't work the same
This problem is again not specific to TypeScript but general to JavaScript.
You want everything to work as expected - inheritance using both old-style functions and explicit prototypes and inheritance with class
and extends
keywords to work plus a lot more.
In other words the object should work the same as other objects declared with class
and instantiated with new
with no fancy stuff.
My rule of thumb: if you can do something with built-ins like Array
(that work with and without new
) then you should do it with our constructor as well.
Problem #5 - it's easy to make a solution with different meta data
Again general to JavaScript.
What you want is not only to get an object that works like you want when you call A()
without new
but you actually want to x instanceof A
to work as expected, you want console.log()
to write the correct name when you want to print the object etc.
This may not be a problem for everyone but needs to be considered.
Problem #6 - it's easy to make a solution with old-school function
It should support the class
syntax instead of going back to function
constructors and prototypes or otherwise you'll lose a lot of useful TypeScript features.
Problem #7 - some solutions work only when transpiled to ES5 or older
This is related to Problem #6 above - if the transpilation target is pre-ES6 then the result will use old-style function
constructors which don't give the error:
TypeError: Class constructor A cannot be invoked without 'new'
(see Problem #1 above)
This may or may not be a problem for you. If you are transpiling for legacy engines anyway then you won't see this problem but when you change the transpilation target (e.g. to avoid high runtime cost of async
/await
polyfills etc.) then you'r code will break. If it's a library code then it will not work for everyone. If it's only for your own use then at least keep it in mind.
Solutions
Here are some of the solutions that I came up with when I was thinking about that some time ago. I am not 100% happy with them, I would like to avoid proxies, but those are currently the only solutions that I found that solve all of the problems above.
Solution #1
One of my first attempts (for more general types see later examples):
type cA = () => A;
function nonew<X extends Function>(c: X): AI {
return (new Proxy(c, {
apply: (t, _, a) => new (<any>t)(...a)
}) as any as AI);
}
interface A {
x: number;
a(): number;
}
const A = nonew(
class A implements A {
x: number;
constructor() {
this.x = 0;
}
a() {
return this.x += 1;
}
}
);
interface AI {
new (): A;
(): A;
}
const B = nonew(
class B extends A {
a() {
return this.x += 2;
}
}
);
One disadvantage of that is that while the constructor name is ok and it prints fine, the constructor
property itself points to the original constructor that was an argument to the nonew()
function instead of to what the function returns (which may or may not be a problem, depending on how you loot at it).
Another disadvantage is the need to declare interfaces to have the types exposed.
Solution #2
Another solution:
type NC<X> = { new (): X };
type FC<X> = { (): X };
type MC<X> = NC<X> & FC<X>;
function nn<X>(C: NC<X>): MC<X> {
return new Proxy(C, {
apply: (t, _, a) => new (<any>t)(...a)
}) as MC<X>;
}
class $A {
x: number;
constructor() {
this.x = 0;
}
a() {
return this.x += 1;
}
}
type A = $A;
const A: MC<A> = nn($A);
Object.defineProperty(A, 'name', { value: 'A' });
class $B extends $A {
a() {
return this.x += 2;
}
}
type B = $B;
const B: MC<B> = nn($B);
Object.defineProperty(B, 'name', { value: 'B' });
Here you don't need to duplicate the type definitions in redundant interfaces but instead you get the original constructor with the $
prefix.
Here you also get inheritance and instanceof
working and the constructor name and printing is ok but the constructor
property points to the $
-prefixed constructors.
Solution #3
Another way to do it:
type NC<X> = { new (): X };
type FC<X> = { (): X };
type MC<X> = NC<X> & FC<X>;
function nn<X>(C: NC<X>): MC<X> {
return new Proxy(C, {
apply: (t, _, a) => new (<any>t)(...a)
}) as MC<X>;
}
type $c = { $c: Function };
class $A {
static $c = A;
x: number;
constructor() {
this.x = 10;
Object.defineProperty(this, 'constructor', { value: (this.constructor as any as $c).$c || this.constructor });
}
a() {
return this.x += 1;
}
}
type A = $A;
var A: MC<A> = nn($A);
$A.$c = A;
Object.defineProperty(A, 'name', { value: 'A' });
class $B extends $A {
static $c = B;
a() {
return this.x += 2;
}
}
type B = $B;
var B: MC<B> = nn($B);
$B.$c = B;
Object.defineProperty(B, 'name', { value: 'B' });
This solution has the constructor
properties of instances point to the exposed (not the $
-prefixed constructor) but makes the constructor
property return true
for hasOwnProperty()
- but false
for propertyIsEnumerable()
so that should not be a problem.
More solutions
I put all of my attempts and some more explanation on GitHub:
I am not completely happy with any one of them but they all work in what they do.
See also my answer to Call constructor on TypeScript class without new