Generic class expression broken if duplicate constructor is removed

93 views Asked by At

I stumbled upon this weird behaviour,

If I remove one of the constructor signatures in IFoo the compiler triggers the following error : Type 'typeof (Anonymous class)' is not assignable to type 'IFoo'.

What is actually happening ?

type Foo<T> = {
  [S in keyof T]: T[S]
}

interface IFoo {
  new <T>(): Foo<T>;
  new <T>(): Foo<T>; // KO if removed
}

const Foo: IFoo = (class {})

const foo = new Foo();

Playground

1

There are 1 answers

0
jcalz On BEST ANSWER

TL;DR: Overloads which are generic are not properly type-checked, as described in microsoft/TypeScript#26631 and microsoft/TypeScript#50050.


Your IFoo interface claims to be the type of a generic class constructor that takes one type argument T but no actual value arguments, and returns a class instance of type Foo<T> which is more or less the same as T (since it's the identity mapped type that copies all non-signature properties). So I had a value Foo of type IFoo, then presumably I could write this:

declare const Foo: IFoo;
const withStringA = new Foo<{a: string}>();
withStringA.a.toUpperCase();
// const withStringA: Foo<{ a: string; }>
const withNumberA = new Foo<{a: number}>();
// const withNumberA: Foo<{ a: number; }> 
withNumberA.a.toFixed();

which would be emitted as the following JavaScript:

const withStringA = new Foo();
withStringA.a.toUpperCase();
const withNumberA = new Foo();
withNumberA.a.toFixed();

But both withStringA and withNumberA are initialized with exactly the same construct call, new Foo(). How can withStringA.a be of type string but withNumberA.a be of type number? There's no plausible mechanism by which such a thing can happen; without magic, IFoo is unimplementable, at least not safely.

In particular, class {} does not properly implement IFoo. It has no properties at all, let alone an a property that is magically string or number depending on information unavailable at runtime. If you try to run this, you will get a runtime error:

const Foo: IFoo = class { };
const withStringA = new Foo<{ a: string }>();
// const withStringA: Foo<{ a: string; }>
withStringA.a.toUpperCase(); // RUNTIME ERROR  x.a is undefined 

So if IFoo cannot be safely implemented, why doesn't the compiler warn you about it?


Indeed, as you noticed, you do get warned when you remove the second construct signature:

interface IFoo {
  new <T>(): Foo<T>;
}

const Foo: IFoo = class { }; // error!
// -> ~~~
// Type 'typeof Foo' is not assignable to type 'IFoo'.

This error is expected and good.

But as soon as you add a second, overloaded construct signature, the error disappears:

interface IFoo {
  new <T>(): Foo<T>;
  new <T>(): Foo<T>; 
}

const Foo: IFoo = class { }; // okay?!

Why?


The issue is that generic overloads are not properly type-checked, as described in microsoft/TypeScript#26631 and microsoft/TypeScript#50050. Type parameters are replaced with the unsafe any type, which means that the compiler checks the assignment of class {} as if IFoo were:

interface IFooLike {
  new(): Foo<any>;
  new(): Foo<any>;
}

which is the same as

interface IFooLike {
  new(): any;
  new(): any;
}

And indeed, class {} does match that type:

const Foo: IFooLike = class { };

So there's no compiler error, even though there should be.

This is effectively a design limitation in TypeScript; it is a bug, but if they fix it, a lot of code which currently works (and happens to be safe) will probably start to spew compiler warnings (because the compiler can't verify overload safety very well).

So, if you do decide to use multiple generic call or construct signatures for a type, be careful with how you implement it, because the compiler can silently fail to catch mistakes.


Playground link to code