microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.04k stars 12.49k forks source link

Polymorphic `this` and Generics #6223

Open kitsonk opened 8 years ago

kitsonk commented 8 years ago

First, thank you team for polymorphic this. It is really handy!

I think I ran into a case though that I am finding challenging, when I need to return a this, but the generics might have changed. For example:

class A<T> {
    private items: T[] = [];
    map<U>(callback: (item: T, idx: number, a: this) => U): this {
        // boring implementation details
    });
}

Where I want to be able to change the generic type for the class with a function, but I will be contracting to return the "current" class, but I want to guard different generics. The following seems logical to me, but doesn't appear to be currently supported:

class A<T> {
    private items: T[] = [];
    map<U>(callback: (item: T, idx: number, a: this) => U): this<U> {
        // boring implementation details
    });
}

Where if no generics arguments are supplied, it is inferred to be the current ones, where as if they are supplied they are substituted.

myitcv commented 8 years ago

@kitsonk this comment and the following discussion I think answers your question

saschanaz commented 8 years ago

I agree that this is related with #6220 and I think TS should be able to express ES6 Promise subclassing behavior.

kitsonk commented 8 years ago

@myitcv thanks, but I don't think it does. I am not talking about inferring higher-order types as seems to be discussed there. I am actually talking about replacing generic type slots with other values in a polymorphic this so that the resulting types can accurately reflect the runtime behaviour of some code.

To express where this becomes a problem, I will extend the class:

class A<T> {
    private items: T[] = [];
    map<U>(callback: (item: T, idx: number, a: this) => U): A<U> {
        // boring implementation details
    });
}

class B<T> extends A<T> {
    foo(): void {};
}

const b = new B<string>().map((item) => Number(item));
// b will be typed as A<number> not B<number>

or

class A<T> {
    private items: T[] = [];
    map<U>(callback: (item: T, idx: number, a: this) => U): this {
        // boring implementation details
    });
}

class B<T> extends A<T> {
    foo(): void {};
}

const b = new B<string>().map((item) => Number(item));
// b will be typed as B<string> not B<number>
myitcv commented 8 years ago

This is taken from my comment in that thread:

export interface Iterable<K, V> {
    map<M>(
      mapper: (value?: V, key?: K, iter?: this) => M,
      context?: any
    ): Iterable<K, M>; // can't use 'this' here
}

The comment // can't use 'this' here is exactly what you're trying to solve for, no?

Even though that particular comment doesn't refer to the problem of extending you refer to, if you look further up the thread you'll see this thread was motivated by exactly the same problem (as far as I can tell)

Igorbek commented 8 years ago
class B<R, T, U> extends A<[C<R, T>]> { ... }
new B<number, string, boolean>().map<Date>(); // <-- what will be here?
zpdDG4gta8XKpMCd commented 8 years ago

FYI #5999

kitsonk commented 8 years ago

@Igorbek I assume you are suggesting the declaration like this:

class A<T> {
    private items: T[] = [];
    map<U>(callback?: (item: T, idx: number, a: this) => U): this {
        // boring implementation details
        return;
    };
}

class B<R, T> extends A<[C<R, T>]> {
    foo(): void {};
}

class C<K, V> {
    map: [K, V][] = [];
}

const b = new B<number, string>().map<Date>();

Which works today and the question you are posing is that because the descent provides a different arity of generics, how would you know what to pass, but this is a problem for current polymorphic this isn't it as well? Because clearly, you can create functions which augment the generics of an interface/class and this does not always represent that properly.

@aleksey-bykov plus #5845 and #1213 and #4967. It is a duplicate and I can understand the reasons behind it, which mostly seem to boil down to "well C# doesn't solve this problem either". But of course TypeScript is not C# and it seems to be a hole in the type system... Polymorphic this wasn't a C# feature too, was it?

I am fine if it gets closed as a duplicate, but I am just not sure if we are simply ignoring a problem... Is there a suggestion of another way of expressing the type? The previous answers appear to be summed up as "um, yeah we can't do that..."

zpdDG4gta8XKpMCd commented 8 years ago

@kitsonk, please find @mhegazy's comment in #5999 where he brings up some non-trivial questions about this feature

please do not confuse it with higher kinded types, they are different

saschanaz commented 8 years ago

So the biggest question currently is what this<T> will mean for subclasses that have different type parameter length?

saschanaz commented 8 years ago
declare class Foo<T> {
    foo(): this<T>;
}
declare class Bar extends Foo<void> { // Error: Foo requires subclasses to have 1 type parameters
}

We can just give an error, no?

kitsonk commented 8 years ago

@aleksey-bykov I think we might be overthinking things, for the sake of edge cases. In my mind it is simple. Polymorphic this would have the same arity of generics as the class that it was declared in. If someone was "stupid" and somehow blatted the underlying generic and that didn't manifest itself in the descendent class, it is almost immaterial, because that is what happens with polymorphic this and generics anyways. So to go back to the original problem:

class A<T> {
    map1<U>(callback: (item: T, idx: number, a: this) => U): this<U>;
    map1<U>(callback: (item: T, idx: number, a: this) => U): A<U>;
    map2<U>(callback: (item: T, idx: number, a: this) => U): this;
}

class B<K, V> extends A<[K, V]> {
}

class C<T> extends A<T> {
}

const b1 = new B<string, number>().map1<boolean>(); // Type is B<string, number>
const b2 = new B<string, number>().map2<boolean>(); // Type is A<boolean>
const b3 = new B<string, number>().map3<boolean>(); // Type is B<string, number>

const c1 = new C<string>().map1<boolean>(); // Type is C<boolean>
const c2 = new C<string>().map2<boolean>(); // Type is A<boolean>
const c3 = new C<string>().map3<boolean>(); // Type is C<string>

Of course B is a problem, but it is a problem no matter what... none of the results accurately describe the intent of the code and some sort of type coercion is required. At least with C, we can accurately describe the intent of the code.

In my mind all we are is choosing our own flavour of stupidity. I prefer the one where polymorphic this can take arguments where the arity of the generics does not change in subclasses.

zpdDG4gta8XKpMCd commented 8 years ago

In my mind all we are is choosing our own flavour of stupidity. I prefer the one where polymorphic this can take arguments where the arity of the generics does not change in subclasses.

I've seen a lot of people who change the arity in a subclass all the time.

class Base<Dont, Know, Anything, Yet> { }
class Intermediate<More, Unknowns> extends Base<Oh, Yes, Now, ItsClear> { }
class Final<One, Last, Thing> extends Intermediate<All, Known> { }

There will be a storm of issues with questions why the arity can't be changed just like now we see people puzzled why this can't be generic.

Bottom line is that the current syntax isn't capable enough to make this feature consistent. New syntax is required. New syntax is a big deal. The feature needs a strong justification and crave from the users. Not saying it's not possible and won't ever happen, my personal take is that HKT's are a better investment as they are more fundamental to the type system than this with type parameters.

saschanaz commented 8 years ago

A short brainstorming without HKT:

declare class Base<T> {
    property: T;
    method<U>(): this<U>; // works as implicit type parameter restriction
}
declare class Subclass1<T, U> extends Base<U /* type parameter linked */> {
    // property: U;
    // method<V>(): Subclass<T, V /* linked */>;
}
declare class Subclass2<T, U> extends Base<void> {
    // property: void;
    // method<V>(): ?? <- Error, subclass should have linked type parameter
}
declare class Subclass3<T> extends Base<void> {
    // property: void;
    // method<V>(): ?? <- Error, subclass should have linked type parameter
}
declare class Subclass4 extends Base<void> {
    // property: void;
    // method<V>(): ?? <- Error, subclass should have linked type parameter
}
niieani commented 8 years ago

@SaschaNaz looks like a good solution! +1

electricessence commented 8 years ago

Yes. I'm dealing with this issue as well. I would prefer 'this' to represent "this type" instead of asserting that it will be 'this'. And it would be nice to be able to map directly to the current type and pass different generic type params.

I have now changed my code to use :this where it really makes the simplest sense. But I've also went forward and changed the return type to :this and then simply forced the result with <any> because I knew the return type was the same. This could be bad in the future if :this did deeper level inspections that asserted non-null tree flows, etc.

Without using :this I clearly have to have more complex class structures and override methods just to ensure the types align.

dead-claudia commented 7 years ago

Note: allowing a generic this could be simulated this way now, using a parameter for what you're adding to it:

declare function use<T>(foo: Foo<T>): void
interface Foo<T> extends T {
    one: string
}

const foo = {
    one: "one",
    two: 2,
}

use(foo) // works now
use<{two: number}>(foo) // equivalent

If you were to allow extends this in all generic contexts, it could just be made redundant in interface extension (everything is already a structural subtype of themselves), so it's possible to add to the type system soundly.

variousauthors commented 6 years ago

@isiahmeadows when I try that code snippet in VSCode I get an error message under the extends T to the tune of An interface may only extend a class or another interface.

dead-claudia commented 6 years ago

Yeah, they fixed the inconsistency. The workaround is to use type intersection, but IMHO it's not a great workaround given it prevents me from assuming this extends T (which is actually useful on occasion, believe it or not), and it's forced me to recast types and parts of my data model at the type level more than once, in ways that were just boilerplatey hacks. I just wish they would allow extends T and extends this in an F-bounded fashion - it'd make certain type-heavy modeling cases so much easier.

rdhelms commented 4 years ago

Checking in since it's been over a year...any new data on this? Are we stuck with intersection types for the indefinite future?

fan-tom commented 4 years ago

A short brainstorming without HKT:

declare class Base<T> {
    property: T;
    method<U>(): this<U>; // works as implicit type parameter restriction
}
declare class Subclass1<T, U> extends Base<U /* type parameter linked */> {
    // property: U;
    // method<V>(): Subclass<T, V /* linked */>;
}
declare class Subclass2<T, U> extends Base<void> {
    // property: void;
    // method<V>(): ?? <- Error, subclass should have linked type parameter
}
declare class Subclass3<T> extends Base<void> {
    // property: void;
    // method<V>(): ?? <- Error, subclass should have linked type parameter
}
declare class Subclass4 extends Base<void> {
    // property: void;
    // method<V>(): ?? <- Error, subclass should have linked type parameter
}

what about that?

declare class Base<T> {
    property: T;
    method<U>(): this<U>; // works as implicit type parameter restriction
}
declare class Subclass1<T, U> extends Base<U /* type parameter linked */> {
    // property: U;
    // method<V>(): Subclass<T, V /* linked */>;
}
declare class Subclass2<T, U> extends Base<void> {
    // property: void;
    // method<V>(): Subclass2<T, U> (extends Base<V>), so property type changed from void to V
}
declare class Subclass3<T> extends Base<void> {
    // property: void;
    // method<V>(): Subclass3<T> (extends Base<V>), so property type changed from void to V
}
declare class Subclass4 extends Base<void> {
    // property: void;
    // method<V>(): Subclass4<T> (extends Base<V>), so property type changed from void to V
}

Can we change base class type parameters when we return this?