Skip to content

Consider base class from extended generic type #2441

Closed

Description

@stephanedr

Sorry if this has already been submitted (was not able to find).
It would be great if TS could consider the base class from an extended generic type.

interface Type<T> {
    new (...args: any[]): T;
}

function ok(type: Type<Error>) {
    return type == RangeError;
}

function fail<T extends Error>(type: Type<T>) {
    return type == RangeError; // Operator '==' cannot be applied to types 'Type<T>' and 'RangeErrorConstructor'.
}

Activity

  1. JsonFreeman commented on Mar 21, 2015

    @JsonFreeman
    Contributor

    Your example references RangeError. But what is the definition of RangeError?

  2. stephanedr commented on Mar 21, 2015

    @stephanedr
    Author

    RangeError is the standard JavaScript type, which extends Error, as defined in lib.d.ts:

    interface RangeError extends Error {
    }
    interface RangeErrorConstructor {
        new (message?: string): RangeError;
        (message?: string): RangeError;
        prototype: RangeError;
    }
    declare var RangeError: RangeErrorConstructor;
    

    Note that this is not my real code, but just a very basic example showing the issue (in Playground for example).
    In ok(), TS correctly considers RangeError as a possible value of type (if we put a type that does not extend Error, e.g. String, TS correctly reports an error).
    In fail(), TS seems to have no knowledge of T (or Type), and rejects all comparisons, even the following:

    function fail<T>(type: Type<T>) {
        return type == Object; // Operator '==' cannot be applied to types 'Type<T>' and 'ObjectConstructor'.
    }
    
  3. stephanedr commented on Mar 21, 2015

    @stephanedr
    Author

    My last sentence is confusing...
    In the last code example, this is normal that TS raises this error, as it doesn't know what T can be, whereas in my initial comment, TS should consider T at least of type Error.

  4. JsonFreeman commented on Mar 21, 2015

    @JsonFreeman
    Contributor

    But T could be some other subtype of Error. What if you had the following:

    interface OtherError extends Error {
        otherProperty: string;
    }
    
    function fail<T extends Error>(type: Type<T>) {
         return type == RangeError;
    }
    var OtherError: Type<OtherError>;
    fail(OtherError);

    In this case, you could end up comparing RangeError to OtherError. But they will never be comparable because RangeErrorConstructor has a prototype property that Type<OtherError> doesn't have, and Type<OtherError> has an otherProperty that RangeErrorConstructor doesn't have.

    So the fact that T is at least Error is not sufficient to ensure that they are comparable. That is why the compiler does not allow it.

  5. stephanedr commented on Mar 21, 2015

    @stephanedr
    Author

    The code can only work with class types, not interfaces.

    Anyway, forget about the example with error classes. Here is another example with my own classes which compiles:

    class A { a: number; }
    class B extends A { b: number; }
    class C extends A { c: number; }
    
    function isB(type: Type<A>) {
        return type == B;
    }
    
    console.log(isB(A)); // false
    console.log(isB(B)); // true
    console.log(isB(C)); // false
    

    The issue I'm reporting is linked to the way I declare the function.
    The following does not compile:

    function isB<T extends A>(type: Type<T>) {
        return type == B; // Operator '==' cannot be applied to types 'Type<T>' and 'typeof B'.
    }
    
  6. JsonFreeman commented on Mar 21, 2015

    @JsonFreeman
    Contributor

    I guess there are two things I don't understand:

    1. Why do you call isB(C) if it is statically guaranteed to be false? You might as well just write false.
    2. Why do you want isB to be generic? There seems to be no benefit to make it generic.
  7. stephanedr commented on Mar 22, 2015

    @stephanedr
    Author

    I indeed confirm this test case has no interest, but I wanted to provide a very short example that highlights the issue.

    My real case is an event/listener mechanism, where the type of events is based on classes.
    For example, the DOM addEventListener() would be reduced to a unique definition:

    addEventListener<T extends Event>(type: Type<T>, listener: (event: T) => any);
    

    instead of the >100 declarations in lib.d.ts (but would need as many classes as possible types...).

    The problem I have is in one of the add listener overrides, I need to compare the given type to a particular event class (to perform extra stuff).

            listen<T extends Event>(type: Type<T>, listener: Listener<T>): void {
                if (<any>type == EvaluateEvent) { // Need to cast to allow the comparison
                    ...
                }
                super.listen(type, listener);
            }
    
  8. JsonFreeman commented on Mar 23, 2015

    @JsonFreeman
    Contributor

    Ah I see. That information helps.

    You can cast to <Type<Event>> to retain some type safety. And you can put that cast either on type or on EvaluateEvent.

    Alternatively, you don't have to cast, you can do something like this inside the listen function:

    var eventType: Type<Event> = type;
    if (eventType == EvaluateEvent) {
       ...
    }

    But I think your objection is that this feels silly. It seems like this is a perfectly reasonable equality comparison, as you are comparing values of seemingly related types. But the type system does not see it that way. For example, you would not want to be allowed to assign EvaluateEvent to type. So the type system requires that you mention some other type that is a supertype of both (in this case Type<Event>).

    I don't think the fix here is to treat T as synonymous with Event as you suggest. We do actually do that when checking that type is assignable to EvaluateEvent, but not when checking that EvaluateEvent is assignable to type. And you wouldn't want that.

    In my opinion, this issue is related to #1447, which exhibits a similar problem for casts, where this one is for equality comparisons. The gist is that there are some operators that would benefit from a looser relation than assignability, a relation that asks "Are these two types vaguely related, even if one is not certainly assignable to the other?". And I think your issue suggests firstly that equality comparisons are one such operator. And secondly that this relation may have to consider type parameters synonymous with their constraints on both sides.

  9. stephanedr commented on Mar 23, 2015

    @stephanedr
    Author

    I suppose I see your point, but I don't think it is related to #1447.
    In #1447, Foo and { a: undefined } are different in both directions.
    In my case, EvaluateEvent is assignable to Event.

    Let's restart with the following code, closer to my real case, though simpler, with 3 implementations of listen() (can be copied/pasted to Playground):

    module A {
        interface Type<T> { new (...args: any[]): T; }
        interface Listener<T> { (event: T): void; }
    
        class Event { a: number; }
        class SetValueEvent extends Event { b: number; }
        class EvaluateEvent extends Event { c: number; }
    
        function listen1(type: Type<Event>, listener: Listener<Event>): void {
            if (type == EvaluateEvent) { }
        }
    
        function listen2<T extends Event>(type: Type<T>, listener: Listener<T>): void {
            if (<Type<Event>>type == EvaluateEvent) { } // Need to cast.
        }
    
        function listen3<T extends EvaluateEvent>(type: Type<T>, listener: Listener<T>): void {
            if (type == EvaluateEvent) { } // No need to cast.
        }
    }
    

    What I don't understand is the difference between:

    • an allowed call to listen1(SetValueEvent, ...) where the condition will always be false,
    • for listen2(), the fact that TS doesn't (want to?) consider that type is at least the Event class/constructor,
    • for listen3(), TS doesn't complain whereas T could be a sub-class of EvaluateEvent, so same situation as for listen2() to me.

    (knowing that in the 3 situations, the comparison is always valid whatever the types, we're comparing 2 constructor functions).

  10. RyanCavanaugh commented on Mar 23, 2015

    @RyanCavanaugh
    Member

    So imagine this diagram of types:

      A
     / \
    B   C
         \
          D
    

    You can do the following sorts of comparisons:

    • A == B
    • C == D
    • A == D

    But not

    • B == C

    If the comparison from B to C were allowed because A exists, then any comparison would be allowed because there is always some A (namely, {}).

    When you have a generic constraint, e.g. T extends A, the compiler assumes that T is more like a B or C than an A. This is because code like this should not compile:

    function foo<T extends Animal>() : T {
      return new Dog(); // Dog extends Animal, just like T!
    }
    var x = foo<Cat>(); // x: Cat but is actually a Dog, wat?

    In your example, this all maps to the diagram above:

    • Event is Type<A>
    • Type<T> where T extends Event starts at Type<A> but is treated like Type<B> (see above)
    • Type<C> is EvaluateEvent
    • Type<T> where T extends EvaluateEvent starts at Type<C> but is treated like Type<D>

    listen1 is OK because comparisons between A and B are OK
    listen3 is OK because comparisons between C and D are OK

    listen2 is not OK because comparisons between B and C are not allowed.

  11. JsonFreeman commented on Mar 23, 2015

    @JsonFreeman
    Contributor

    The key thing to realize in your example is the following:

    • In listen1: Every Type<EvaluateEvent> is a Type<Event>, so you can compare type to EvaluateEvent.
    • In listen3: Every Type<T> (where T extends EvaluateEvent) is a Type<EvaluateEvent>, so you can compare them.
    • In listen2: Not every Type<T> (where T extends Event) is a Type<EvaluateEvent>, nor is every Type<EvaluateEvent> a type Type<T>, so you cannot do the comparison.
  12. added
    By DesignDeprecated - use "Working as Intended" or "Design Limitation" instead
    SuggestionAn idea for TypeScript
    on Mar 24, 2015
  13. stephanedr commented on Mar 24, 2015

    @stephanedr
    Author

    Thanks for your answers.
    I was too much focused on the very particular case that Type<T> is just a function (the constructor of T, so any function can be compared to any function, whatever T), whereas I understand that TS needs to keep this distinction for all other cases.

  14. added
    CanonicalThis issue contains a lengthy and complete description of a particular problem, solution, or design
    on Jul 3, 2015
  15. locked and limited conversation to collaborators on Jun 18, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    By DesignDeprecated - use "Working as Intended" or "Design Limitation" insteadCanonicalThis issue contains a lengthy and complete description of a particular problem, solution, or designSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions