Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Regression: 2.4.1 Generic type mismatch #16985

Closed
isuda opened this issue Jul 6, 2017 · 37 comments
Closed

Regression: 2.4.1 Generic type mismatch #16985

isuda opened this issue Jul 6, 2017 · 37 comments
Assignees
Labels
Bug A bug in TypeScript Fixed A PR has been merged for this issue

Comments

@isuda
Copy link

isuda commented Jul 6, 2017

This is a simplified piece of code which demonstrates the problem. It compiles fine in 2.3.4 & 2.4.0, but produces a not assignable error in 2.4.1 and nightly.

TypeScript Version: 2.4.1 / nightly (2.5.0-dev.20170629)

Code

interface MyInterface {
  something: number;
}

class MyClass<T extends MyInterface> {
  doIt(data : Partial<T>) {}
}

class MySubClass extends MyClass<MyInterface> {}

function fn(arg: typeof MyClass) {};

fn(MySubClass);

Expected behavior:

No compile error.

Actual behavior:

test.ts(13,4): error TS2345: Argument of type 'typeof MySubClass' is not assignable to parameter of type 'typeof MyClass'.
  Type 'MySubClass' is not assignable to type 'MyClass<T>'.
    Types of property 'doIt' are incompatible.
      Type '(data: Partial<MyInterface>) => void' is not assignable to type '(data: Partial<T>) => void'.
        Types of parameters 'data' and 'data' are incompatible.
          Type 'Partial<T>' is not assignable to type 'Partial<MyInterface>'.
@ericeslinger
Copy link

ericeslinger commented Jul 7, 2017

I'm having a similar problem, I think.

interface ModelData {
  type: string;
  attributes: { [key: string]: any };
}
class Model<MD extends ModelData> {
  // stuff
  static type = 'abstract';
  set(v: MD): this {
    // set value to v
    return this; // for chaining
  }
}
class Registry {
  private reg: { [key: string]: typeof Model };
  add(M: typeof Model) {
    reg[M.type] = M;
  }
}
interface TestData extends ModelData {
  type: 'test';
  attributes: {
    foo: string;
  };
}
class TestModel extends Model<TestData> {
  static type = 'test';
}
const reg = new Registry();
reg.add(TestModel); // ERROR in TSC 2.4.1, not 2.4.0

At least, I think this is the same sort of problem - I want to express that my registry contains constructors for Model objects, and that Model.set() returns this for chaining. Changing Registry.add to signature add(M: any) fixes it.

@isuda
Copy link
Author

isuda commented Jul 7, 2017

@ericeslinger - yeah, definitely looks like the same kind of problem.

Using any is not a solution I would like to use :)

@ericeslinger
Copy link

This fixes the urgent symptom of the problem, but doesn't seem to address the underlying problem. To wit: the new generic checks don't seem to play nicely with class generics and subclasses. My entire data model is predicated on having a generic Model class to represent a data model and its actions (get / set / reset / etc), and then the user subclassing Model with a concrete instance of Schema, so we know the specific shape of the data returned by get().

I'd prefer to write canonical TS, not stuff that only builds if you turn off default compiler settings.

@ikatyang
Copy link
Contributor

ikatyang commented Jul 9, 2017

I think the issue here is that typeof Model is considered typeof Model<???>, I have no idea how TS handle that, but if you define an AnyModel class (which is considered typeof Model<ModelData> or typeof Model<any>) can fix this problem.

(diff)

// ...

+declare class AnyModel extends Model<ModelData> {};

class Registry {
-  private reg: { [key: string]: typeof Model };
+  private reg: { [key: string]: typeof AnyModel };
-  add(M: typeof Model) {
+  add(M: typeof AnyModel) {
-    reg[M.type] = M;
+    this.reg[M.type] = M;
  }
}

// ...

const reg = new Registry();
-reg.add(TestModel); // ERROR in TSC 2.4.1, not 2.4.0
+reg.add(TestModel); // no error

(entire)

interface ModelData {
  type: string;
  attributes: { [key: string]: any };
}
class Model<MD extends ModelData> {
  // stuff
  static type = 'abstract';
  set(v: MD): this {
    // set value to v
    return this; // for chaining
  }
}

declare class AnyModel extends Model<ModelData> {};

class Registry {
  private reg: { [key: string]: typeof AnyModel };
  add(M: typeof AnyModel) {
    this.reg[M.type] = M;
  }
}
interface TestData extends ModelData {
  type: 'test';
  attributes: {
    foo: string;
  };
}
class TestModel extends Model<TestData> {
  static type = 'test';
}
const reg = new Registry();
reg.add(TestModel); // no error

@mat3e
Copy link

mat3e commented Jul 9, 2017

I'm having a similar issue. Seems like stricter checks on generics and typeof are not the perfect match.

interface IDefinition { }

class DefinitionBase implements IDefinition { }

class Def1 extends DefinitionBase { }
class Def2 extends DefinitionBase { }

// ---------------------- usage ----------------------
interface ITest<D extends IDefinition> {
    definition: D;
}

abstract class TestBase<DImpl extends DefinitionBase> implements ITest<DImpl> {
    abstract definition: DImpl;
}

class ConcreteTest1 extends TestBase<Def1> {
    definition: Def1;
}
class ConcreteTest2 extends TestBase<Def2> {
    definition: Def2;
}

// classes container
class AllTestClasses<T extends typeof TestBase> {
    array: T[];
}

// ---------------------- fail ----------------------
var t = new AllTestClasses();
t.array.push(ConcreteTest1); // Def1 vs. DImpl error
t.array.push(ConcreteTest2); // Def2 vs. DImpl error

Errors:

test.ts(31,14): error TS2345: Argument of type 'typeof ConcreteTest1' is not assignable to parameter of type 'typeof TestBase'.
  Type 'ConcreteTest1' is not assignable to type 'TestBase<DImpl>'.
    Types of property 'definition' are incompatible.
      Type 'Def1' is not assignable to type 'DImpl'.
test.ts(32,14): error TS2345: Argument of type 'typeof ConcreteTest2' is not assignable to parameter of type 'typeof TestBase'.
  Type 'ConcreteTest2' is not assignable to type 'TestBase<DImpl>'.
    Types of property 'definition' are incompatible.
      Type 'Def2' is not assignable to type 'DImpl'.

The only way to make it work with TS 2.4.1 is to use "noStrictGenericChecks": true option. Hard to apply the workaround from @ikatyang...

@estaub
Copy link

estaub commented Jul 10, 2017

@mat3e That error actually looks right to me, but my attempted "fix" for it produces an even stranger error, IMHO.

Changing AllTestClasses to:

// classes container
class AllTestClasses {
    array: TestBase<DefinitionBase>[];
}

results in:

Error:(110, 14) TS2345:Argument of type 'typeof ConcreteTest1' is not assignable to parameter of type 'TestBase'.
Property 'definition' is missing in type 'typeof ConcreteTest1'.
Error:(111, 14) TS2345:Argument of type 'typeof ConcreteTest2' is not assignable to parameter of type 'TestBase'.
Property 'definition' is missing in type 'typeof ConcreteTest2'.

@ikatyang
Copy link
Contributor

@mat3e

// ...

+declare class AnyTestBase extends TestBase<DefinitionBase> {
+  definition: DefinitionBase;
+};

// ...

-class AllTestClasses<T extends typeof TestBase> {
+class AllTestClasses<T extends typeof AnyTestBase> {
  array: T[];
}

// ...

@estaub

TestBase<DefinitionBase> is an instance type (members = properties)

typeof TestBase is a class type (members = static properties + constructor)

@HerringtonDarkholme
Copy link
Contributor

Reduced reproduction:

interface MyInterface {
  something: number;
}

var doIt = <T extends MyInterface>(d: Partial<T>) => {}
doIt = (d: Partial<MyInterface>) => {}

@Saviio
Copy link

Saviio commented Jul 12, 2017

Here's another reproduction:

class Base<P = {}> {
  constructor(protected p: P) { }
}

class Baz extends Base { }

const factory = (Clazz: typeof Base) => {
  return class extends Clazz { }
}

const xs = factory(Baz)

Error message:

error TS2345: Argument of type 'typeof Baz' is not assignable to parameter of type 'typeof Base'.
  Type 'Baz' is not assignable to type 'Base<P>'.
    Types of property 'p' are incompatible.
      Type '{}' is not assignable to type 'P'.

@estaub
Copy link

estaub commented Jul 12, 2017

(See similar #16985 (comment) )
It seems like most or all of the examples rely on the inference:

If A extends B
then typeof A is a typeof B

I have no idea when-or-if this is-or-should-be a valid inference.
Can anyone cite anything relevant?

@Saviio
Copy link

Saviio commented Jul 12, 2017

@estaub ,

I think this is a bug about type inference, since I can compile these code successfully in Scala:

class Base { }
class Foo extends Base { }

def checkType(c: Class[_ <: Base]) = println("Get Class Type: " + c.getName)

val clazz1 = (new Foo).getClass
checkType(clazz1) // Get Class Type: Playground$Foo
class Base[+A] { }
class Foo[A] extends Base[A] { }
class Baz { }
class Bar extends Baz { }


def checkType[A <: Baz](c: Class[_ <: Base[A]]) = println("Get Class Typ1e: " + c.getName)

val clazz1 = (new Foo[Bar]).getClass

checkType[Baz](clazz1)

@ikatyang
Copy link
Contributor

@estaub

If A extends B
then typeof A is assignable to typeof B

This is true, but if there are some generics, for example:

If A extends B<T>
Is typeof A assignable to typeof B?

If typeof B is considered typeof B<any>, then typeof A is assignable to typeof B
If typeof B is considered typeof B<T>, then typeof A is not assignable to typeof B

This is all about how strict it should be, I have no idea if this is a bug or not.


@Saviio

typeof is a value-level operator, so that typeof Base is still considered typeof Base<P> instead of typeof Base<{}>, the following cases are passed, see my comment above for more detail.

class Base<P = {}> {
    constructor(public p: P) { }
}

class AnotherBase<P> {
    constructor(public p: P) { }
}

class Baz extends Base {}

const factory = (Clazz: typeof Base) => {
    return class extends Clazz {}
}

const xs = factory(AnotherBase) // passed
class Base<P = {}> {
    constructor(protected p: P) { }
}

class Foo extends Base {}
class Baz extends Base {}

const factory = (Clazz: typeof Foo) => {
    return class extends Clazz {}
}

const xs = factory(Baz) // passed

@Saviio
Copy link

Saviio commented Jul 12, 2017

@ikatyang I fully understand, but I still think the value of default generic type should be considered, otherwise this would be a incomplete feature in my opinion.

@ikatyang
Copy link
Contributor

ikatyang commented Jul 12, 2017

@Saviio

Ah.., typeof is just a value-level operator, so that it is impossible to do some type-level operation, the only problem is that should typeof B be considered typeof B<any>?

It seems the previous behavior inferred all generics as any before comparing signatures, so they passed in the past, see #17123 (comment).

I'm not sure if this is the expected behavior, since typeof SomeClass looks like a special case.

@Saviio
Copy link

Saviio commented Jul 12, 2017

@ikatyang , Mmm, thank you for your explanation, I think I have some misunderstanding about typeof operator in TS. Maybe in my case I should change the code to:

class Base<P = {}> {
  constructor(protected p: P) { }
}

class Baz<P = {}> extends Base<P> { }

const factory = (Clazz: typeof Base) => {
  return class extends Clazz { }
}

const xs = factory(Baz)

@bfricka
Copy link

bfricka commented Jul 12, 2017

Potentially similar issue:

// rxjs mapTo
export function mapTo<T, R>(this: Observable<T>, value: R): Observable<R> {
  return this.lift(new MapToOperator(value));
}

// Boolean issues
const trueObs$: Observable<true> = someObs$.mapTo(true);
const falseObs$: Observable<false> = otherObs$.mapTo(false);

yields

TS2322:Type 'Observable<boolean>' is not assignable to type 'Observable<true>'.
  Type 'boolean' is not assignable to type 'true'.

TS2322:Type 'Observable<boolean>' is not assignable to type 'Observable<false>'.
  Type 'boolean' is not assignable to type 'false'.

Removing the types works (const trueObs$ = someObs$.mapTo(true)), but I want to denote that the observables will only be specifically true or false, and it's silly that you'd have to do something like const trueObs$: Observable<true> = someObs$.mapTo<any, true>(true) to get the correct behavior.

@ikatyang
Copy link
Contributor

@bfricka

That seems another issue about literal-type widening, for example:

const x1 = {v: true}; //=> {v: boolean}
const x2 = {v: true as true}; //=> {v: true}
interface X<T> {
  v: T;
}

declare function getX<T>(v: T): X<T>;

getX(true); //=> X<boolean>
getX<true>(true); //=> X<true>

@bfricka
Copy link

bfricka commented Jul 13, 2017

Indeed, it just seems intuitive to me that by supplying an explicit type (const x: X<true> = getX(true)) to a more specific type of boolean you're supplying the requisite information. I'm sure by your response that this has been discussed at length?

This syntactic detail becomes more noticeable to me with a function accepting more than one generic parameter. In that case it becomes less clear what x is. Sure, you could have the explicit type along with explicit generic types, but that's a bit syntactically clunky in my book.

Side by side in code it looks a bit silly, no?:

interface X<T> {
  v: T
}

const getX = <T>(v: T): X<T> => ({v});
const x: X<true> = getX(true);
// Fail

const getY = <T>(v: T): T => v;
const y: true = getY(true);
// Success!

@izatop
Copy link

izatop commented Jul 18, 2017

Seems like that this same problem

export type Foo = {
    x: number;
}

export class A<T extends Foo> {
    constructor() {
        let m: T = {x: 1};
    }
}

output
Error:(88, 13) TS2322:Type '{ x: number; }' is not assignable to type 'T'.

Updated

@ikatyang
Copy link
Contributor

ikatyang commented Jul 18, 2017

@izatop it seems working as intended, consider the following case:

const a = new A<{y: number}>();
// `T` is `{y: number}` in this case, `{x: 1}` is not assignable to `T`

and default generic does nothing here, it is just a default type.

Only T and its super-type can be assigned to T.

@izatop
Copy link

izatop commented Jul 18, 2017

@ikatyang sorry, I wrote wrong example. See updated comment.

@ikatyang
Copy link
Contributor

@izatop

Only T and its super-type can be assigned to T.

Super-type of T is something like <U extends T>.

<T extends Foo> means T at least have properties from Foo, but it may have other properties, so it is still not assignable, consider the following case:

const a = new A<{x: number, y: number}>();
// `T` is `{x: number, y: number}` in this case, `{x: 1}` is not assignable to `T`

@izatop
Copy link

izatop commented Jul 18, 2017

@ikatyang thanks, now it seems to clear.

@ericeslinger
Copy link

@ikatyang, what I don't get in your fix to my problem is why creating a class AnyModel extends Model<ModelData> fixes things - it feels to me like having a registry that's explicitly there to store Model<MD extends ModelData> (which is how the Model class is generically templated, it is not Model<MD>) makes sense in terms of the type declaration. Artificially narrowing the registry to only include AnyModel extends Model<ModelData> doesn't seem necessary, unless I'm missing something fundamental about type assignment (or about how declaring a return type of this works in generic types.

if needed, I'd prefer to type the registry as private reg: { [key: string]: typeof Model<ModelData> } and then at least not have to define some useless concrete but unused abstract reference type AnyModel, but that doesn't seem to be allowable - you can't say reg: typeof Model<ModelData> or reference generics at all in a typeof.

For now, I guess I'll just disable strict generic checking or do some kludgy workaround, but I'd be interested in hearing if this is a compiler bug or if it is just me not thinking correctly about how one

  • stores an array of constructor functions that are all concrete instantiations of generic templated classes and;
  • allows for this returning chainable methods on those templated classes.

@ikatyang
Copy link
Contributor

@ericeslinger

As I mentioned above, this is caused by stricter generic checks (#16368).

Before that PR, they squashed all generics to any before comparing signatures. (See #17123 (comment))

After that PR, all generics are still own its original shape, thus SomeModel is assignable to AnyModel, but not assignable to Model, since Model is defined as Model<some_generic> and SomeModel does not have any generic that match the shape.

I'm not sure if this is Working as intended or a bug, since typeof SomeClassWithGenerics looks like a special case to me and currently no TS team member commented here, maybe you have to ping them or something else, I have no idea how they think about this.

@ericeslinger
Copy link

ericeslinger commented Jul 19, 2017

Yeah, I think the specific issue here is how the type of a polymorphic this return is computed.

EDIT: Nevermind - just read my error message more clearly. It's the argument to set that's the problem, not the polymorphic this.

@DanielRosenwasser DanielRosenwasser added the Needs Investigation This issue needs a team member to investigate its status. label Jul 19, 2017
@DanielRosenwasser DanielRosenwasser added Bug A bug in TypeScript and removed Needs Investigation This issue needs a team member to investigate its status. labels Jul 19, 2017
@DanielRosenwasser
Copy link
Member

I'll note that Partial<T> is necessary to repro the original test case.

@jcormont
Copy link

jcormont commented Jul 20, 2017

(Note: Issue linked above is for an infinite recursion crash in the compiler that most likely occurs due to the same changes in the compiler, since solving the original issue here also solved the crash. Looks like this code in the compiler isn't 100% correct, given the number of infinite recursion bugs lately).

My understanding is that most people here feel that this should have been a breaking change, because given class A { /* ... */ }, it would seem that typeof A is a good way to describe "any class that derives from A" (or A itself, of course), much better than Function or an interface type would.

But in v2.4.1, if A has type parameters (and uses them), say class A<T> { value: T }, then typeof A doesn't mean the same thing anymore, because it breaks for class B extends A<number>: B is not assignable to typeof A because number is not assignable to T. Hmm... isn't the point here that T is generic in the first place?

Whether typeof A should originally have been correct or not (which it isn't, in the strictest sense, I have to agree: the class type is different, although the value should still be assignable), it was accepted by the compiler before v2.4.1. And in any case, typeof A<T> is a syntax error so now we don't have a good way to express the same thing anymore...? (i.e. "every class derived from A") Then again, I'm pretty sure C# and Java also don't have a way to express this.

This is aggravated by the fact that this issue extends to existing .d.ts files which no longer work.

Less contrived example:

/** A generic UI component that renders to a DOM element */
abstract class UIComponent<RenderedElementT extends HTMLElement> {
  public static staticMethod() { /* ... */ }
  // more static methods here ...

  public abstract render(): RenderedElementT;

  // ...
}

class TextField extends UIComponent<HTMLInputElement> {
  public render() { return document.createElement("input") }

  // ...
}

function findComponentsByType(ComponentClass: typeof UIComponent) {
  // The issue here is that e.g. TextField is not assignable to `typeof UIComponent`
  // but then how do we make sure that only a "component class" can be passed in?
  // other than having to resort to a "class object" interface type that includes
  // a constructor and all static methods, which needs to be declared separately
}

var results = findComponentsByType(TextField);

@ahejlsberg
Copy link
Member

The issue in the OP is that the mapped type relationship implemented in #13448 doesn't correctly handle the case where S is a constrained generic type. This causes the following error:

interface Foo { a: string, b: string }

function f<T extends Foo>(a: Partial<Foo>, b: Partial<T>) {
    a = b;  // Error, but shouldn't be
}

With the stricter generic checks introduced in #16368 we no longer erase type parameters to type any and therefore this issue comes to light. Shouldn't be hard to fix.

@awaltert
Copy link

Hi,

since TS 2.4.1, I'm facing errors when using type inference with generic functions.

Example:

type transformerFn = <T, U>(input: T) => U;
const toUpper: transformerFn = (str: string): string => str.toUpperCase();

Expected result:
Both, T and U should be inferred to be type string.

Actual result:
tsc produces the following output:

error TS2322: Type '(str: string) => string' is not assignable to type 'transformerFn'.
Types of parameters 'str' and 'input' are incompatible.
Type 'T' is not assignable to type 'string'.

Affected versions:
2.4.1 & 2.42

Workaround:
I can workaround the described error by setting noStrictGenericChecks to true.

I use this pattern quite often to describe a common type interface, which can be configured with concrete implementations. And therefore, I relying on the type inference system in these cases.

Can someone please tell me if this is related to this issue or if I should open a new issue for the problem, of if this is an error at all!?

Thanks in advance 😄

@estaub
Copy link

estaub commented Jul 24, 2017

@awaltert I'm no expert, but no, that doesn't appear to be the same problem. As in so many other places, once you tell Typescript the type of something, it won't do any inferencing to refine that definition. I wish there was a way to say "go ahead and refine my definition by inferencing", but there ain't.

The solution is to not provide the type (where inferencing is possible, as here), or provide it in all the details required downstream.
So these work:
const toUpper: transformerFn<string,string> = (str: string): string => str.toUpperCase();
or
const toUpper = (str: string): string => str.toUpperCase();

Also, the <T,U> is in the wrong place in your example, though it compiles. I don't know what it does where it is, but it doesn't work properly. It should be

type transformerFn<T, U> = (input: T) => U;

@ahejlsberg ahejlsberg added the Fixed A PR has been merged for this issue label Jul 25, 2017
@awaltert
Copy link

@estaub Thanks for the quick response. 👍 With the given code example, I tried to reduce my problem to a basic example, which could lead to misunderstandings, sorry for that. The explicit type declaration const toUpper: transformerFn was only present to show the error.

In general, you can provide the "variable declaration" for the generic type arguments on the right side of the equal sign. The difference compared with the left side is:

  • on the left: you have to provide explicit types, when using the symbol
  • on the right: use type inference, when using the symbol

It is used to declare the type variable, which are then known in the scope of the function. Please have a look at the example below. Because of this mechanism, the concrete types can be inferred 😄

const toLength = (str: string) => str.length; // string => number

type mappable = <T, U>(transformer: (input: T) => U) => (inputArray: T[]) => U[]; // use type inference
const mappable: mappable = (transformer) => (input) => input.map(transformer);

const lengths = mappable(toLength); // inferred as lengths:: string[] => number[]
console.log(lengths(["generics", "type", "inference"])); // outputs: [ 8, 4, 9 ]​​​​​

So my question still remains: Why is the error string not assignable to T shown?

As this issue is already fixed, should I create a new issue for discussion purpose?

@gcnew
Copy link
Contributor

gcnew commented Jul 26, 2017

@awaltert The error is correct. The type

type transformerFn = <T, U>(input: T) => U

means the function transformFn maps any type T to any other type U. Obviously there is no implementation for such a function, because we can't create values for every possible type out of thin air. E.g. the implementation you've provided accepts only a string argument and returns a corresponding string, but not every type.

Your second example is actually substantially different.

type mappable = <T, U>(transformer: (input: T) => U) => (inputArray: T[]) => U[];

reads: mappable is a function transformation, that accepts a function with some parameter T that returns some parameter U and transforms it into a function that accepts an inputArray array of that very same T an returns an array of Us.

The biggest difference is that when you're annotating a variable as in your first example, your opting in for the generic version, i.e. "for every X". However, when the generics are used as function arguments, they mean "for some specific (provided or inferred) X".

Edit: To exemplify the difference, consider that instead of the implementation you've provided for mappable, you had written (note the : string annotations):

const mappable: mappable = (transformer: (x: string) => string) => (input: string[]) => input.map(transformer);

Would that have had been correct?

@jcormont
Copy link

Wait hang on, that's a super specific solution (as mentioned by @DanielRosenwasser on the PR...)

How would this solve the issue with typeof of generic classes, brought up by @ericeslinger and @Saviio and myself. This has the same root cause but is not solved by this fix:

/** A generic UI component that renders to a DOM element */
abstract class UIComponent<RenderedElementT extends HTMLElement> {
  public static staticMethod() { /* ... */ }
  // more static methods here ...

  public abstract render(): RenderedElementT;

  // ...
}

class TextField extends UIComponent<HTMLInputElement> {
  public render() { return document.createElement("input") }

  // ...
}

function findComponentsByType(ComponentClass: typeof UIComponent) {

  // THIS::::

  // The issue here is that e.g. TextField is not assignable to `typeof UIComponent`
  // but then how do we make sure that only a "component class" can be passed in?
  // other than having to resort to a "class object" interface type that includes
  // a constructor and all static methods, which needs to be declared separately
}

var results = findComponentsByType(TextField);

Or do we open up another issue for this / is there another issue open to track this?

@ahejlsberg
Copy link
Member

ahejlsberg commented Jul 28, 2017

@jcormont The error reported in the example above is intended and is one the checker wasn't capable of finding before #16368. In the example, typeof UIComponent has a constructor function signature

new<RenderedElementT extends HTMLElement>(): UIComponent<RenderedElementT>;

but this contract isn't satisfied by the constructor function signature in typeof TextField:

new(): TextField;

In intuitive terms, the generic constructor in UIComponent promises that you can construct exactly typed elements of any type that derives from HTMLElement, but TextField only implements construction of one kind of subtype. Therefore it isn't safe to treat the TextField constructor as a UIComponent constructor.

There are several ways you could structure a correct implementation. One would be to write an actual interface declaration for the parameter of findComponentsByType that is satisfied by any class derived from UIComponent, e.g.

interface ComponentFactory {
    new(): UIComponent<HTMLElement>;
}

function findComponentsByType(ComponentClass: ComponentFactory) {
    // ...
}

Alternatively you could introduce a base class with a constructor contract that is satisfied by all derived classes, e.g.

abstract class ComponentBase {
    public abstract render(): HTMLElement;
    // ...
}

abstract class UIComponent<RenderedElementT extends HTMLElement> extends ComponentBase {
    public abstract render(): RenderedElementT;
    // ...
}

class TextField extends UIComponent<HTMLInputElement> {
    public render() { return document.createElement("input") }
    // ...
}

function findComponentsByType(ComponentClass: typeof ComponentBase) {
    // ...
}

Either way, within the implementation of findComponentsByType you now have an appropriately typed constructor that doesn't promise more than it can deliver.

@jcormont
Copy link

Thanks, that makes sense now. It's still not very intuitive, especially with abstract classes (where the constructor isn't callable anyway so you would usually pass the typeof around as a reference to the Function for direct comparison, or calling static methods), but I see the problem.

Since the interface ComponentFactory in your solution would lack all static members/properties of the UIComponent class, is there any reason why the following couldn't be made to work? (I'm aware that right now 'extends typeof' is a syntax error, but now there may be a way to make it work?)

interface ComponentFactory extends typeof UIComponent { // <====  why not
    // all static methods/properties inherited from UIComponent
    // plus constructor without type parameters:
    new(): UIComponent<HTMLElement>;
}

function findComponentsByType(ComponentClass: ComponentFactory) {
    ComponentClass.doSomething(); // <== static method?
    // ...
}

@ahejlsberg
Copy link
Member

An extends clause requires its argument to be a type reference (a type name possibly followed by type arguments in angle brackets), but you can always satisfy this syntactic restriction by declaring a type alias for the particular type. However, even if you extend (an alias for) typeof UIComponent you still end up with the constructor signatures from that type, and new ones you add just become additional overloads. But that you could work around by applying a mapped type (which filters out all non-public members and all call and construct signatures). So, this appears to do the trick:

type PublicMembersOf<T> = { [P in keyof T]: T[P] };

interface ComponentFactory extends PublicMembersOf<typeof UIComponent> {
    new(): UIComponent<HTMLElement>;
}

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Bug A bug in TypeScript Fixed A PR has been merged for this issue
Projects
None yet
Development

No branches or pull requests