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

Type's generic type constraint on member level #1290

Closed
Igorbek opened this issue Nov 27, 2014 · 16 comments
Closed

Type's generic type constraint on member level #1290

Igorbek opened this issue Nov 27, 2014 · 16 comments
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript

Comments

@Igorbek
Copy link
Contributor

Igorbek commented Nov 27, 2014

This is a suggestion for spec change.

Problem

Sometimes type's members could only be applicable with some restrictions of enclosing type parameter (generic type constraints).

For instance, this is use case of RxJS:

var xs: Observable<Observable<string>>;
xs.mergeAll();  // mergeAll is applicable here

var ys: Observable<string>;
ys.mergeAll();  // mergeAll is NOT applicable here

Original Rx.NET Merge method implemented as C# extension method on type IObservable<IObservable<T>>. In RxJS it's implemented as an instance method.

Current RxJS typescript definition just use unsafe trick:

export interface Observable<T> {
  ...
  mergeAll(): T;
  ...
}

But it can be still unsafely called for as instance of any Observable<T>.

Possible solutions

Extension methods

Something like C# extension methods. But don't think it's useful in such cases.

declare extensions {
  mergeAll<T>(this Observable<Observable<T>> source): Observable<T>;
}

BTW, extensions method could be other cool feature, which change call method (from instance-like to static-like).

Multiple interface definitions with different constraints

interface A<T> { x(): void; }
interface A<T extends B> { y(): void; }
var a: A<number>;
var b: A<B>;

a.x(); b.x(); // ok
b.y(); // ok
a.y(); // error

Member-level constraints with reference to enclosing type arguments

interface A<T> {
  x(): void;
  y<T extends B>(): void; // only applicable if T extends B
  z<T extends B>: number; // for any property
  // alternative syntax options:
  <T extends B>z: number;  // 1

  z: number where T extends B;  // 2
  y(): void where T extends B;
}

I'll suggest this option. Moreover the previous solution options is special case of this one (incompatible constraints are merged on member level).

@Igorbek
Copy link
Contributor Author

Igorbek commented Nov 28, 2014

BTW, Happy Thanksgiving Day!

@RyanCavanaugh
Copy link
Member

Are there other examples of where this would be needed? It seems pretty specific.

@Igorbek
Copy link
Contributor Author

Igorbek commented Dec 2, 2014

For me it looks very useful, and I can imagine lot of situations when it necessary (or at least more expressive then using other patterns to describe).

I'll be looking for such use cases in DT definitions and JS libraries. Can't remember anyone at the moment (you caught me). I'll provide some use cases.

@mhegazy mhegazy added the Suggestion An idea for TypeScript label Mar 24, 2015
@mhegazy mhegazy added the Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. label Dec 9, 2015
@masaeedu
Copy link
Contributor

Possible dupe of #209

@thorn0
Copy link

thorn0 commented Jan 18, 2016

@RyanCavanaugh

Are there other examples of where this would be needed? It seems pretty specific.

There is one more example in #6529.

@niieani
Copy link

niieani commented Feb 17, 2016

Additional explanation and examples + possible usage taken from the duplicate #7083:

It's currently possible to do this:

  method<A extends Array<number>>(one):number;
  method<A extends Array<string>>(two):string;

But not possible to do this:

interface Example<T extends Array<number>> {
  method(one):number;
}
interface Example<T extends Array<string>> {
  method(two):string;
}

This means we cannot have different typings depending on the generic type constraint of the interface.
I've even tried a hack like this:

interface Example<T extends Array<any>> {
  method<A extends T & Array<number>>(one):number;
  method<A extends T & Array<string>>(two):string;
}

I was hoping that TS will infer the type of the T array from the intersect, unfortunately that does not happen (it offers both methods, regardless of whether Example is an instance of Example<Array<number>> or of Example<Array<string>>).

Among other uses, this feature would be useful for a variety of database libraries that return internal types that can have their own operations done on them. For example, when I map an DbArray<DbNumber>, I should get a different set of methods than when operating on DbArray<DbString>.

The current workaround is to ask the user to explicitly declare both - the outer and the inner type, e.g.:

interface Example<TOuter, TInner> {
  map<TOut>((item: TInner) => TOut): TOut;
  // ...TOuter used in another method
}

However, this TInner is redundant (it's already inside of TOuter) and sometimes not relevant at all.
With more complex types this can cause the introduction of even more generic types, that grow like cancer in the declarations (interface Example<A, B, C, D, ...>) - the more possibilities there are, the more explicit declarations need to be done at the interface level. This has been an obstacle to creating proper type definitions for RethinkDB DefinitelyTyped/DefinitelyTyped#4551.

Last example: an interface DbValue can be a generic of any primitive type in the database: an array, a string or a number.
For an instance of an array, you'd need to pass in DbValue<Array<number>, number>, yet for an instance of string, the second parameter is irrelevant: DbValue<string, void>.

What we'd need is to be able to constrain not only by method's own generics, but by the containing interfaces generics, for method signatures themselves, something like:

interface Example<T> {
  // here the constraint is not of the method, but of the type
  method(one):number where T extends Array<number>;
  method(two):string where T extends Array<string>;
}

@AlexGalays
Copy link

AlexGalays commented May 21, 2016

This is very useful.

Any type that provides operations only if its type parameter is specialized enough can greatly beneficiate from the type safety this would bring. Ex: Flattening any kind of nested container like Streams, Arrays. You don't want to be able to call flatten on Array<number> but you should be able to do it on Array<Array<number>>

Another example: Lodash's max method. Calling max on a collection is making the assumption that its elements can be compared. _([ { a: true }, { a: false } ]).max makes no sense, yet it compiles just fine.

That said... Using a functional style instead of an OO style make all these issues go away :)

@niieani
Copy link

niieani commented Jul 13, 2016

Would the OP @Igorbek or anyone else like to collaborate on a proposal for this feature?

@Igorbek
Copy link
Contributor Author

Igorbek commented Jul 13, 2016

@niieani Absolutely. Send me if you have something already. If not, I'll start to formulate a proposal.

@niieani
Copy link

niieani commented Jul 13, 2016

I don't have anything yet, but we can use parts of your initial post and mine #1290 (comment). If you start working on something, let's use something like Google Docs and link here, so we can collaborate on the document.

@Igorbek
Copy link
Contributor Author

Igorbek commented Jul 13, 2016

@ceymard
Copy link

ceymard commented Aug 26, 2016

Hello,

note I have updated this example as per @niieani's comment below. This solution is not perfect, but it does help while there is nothing.

This works as of typescript 2.0.0 ;

class Test<T> {

    // note that if there is no member of type T, the errors are not spotted
    val: T

    // note : can be called with Test<something extends string> as well
    strings(this: Test<string>) {
        console.log('strings !')
    }

    numbers(this: Test<number>) {
        console.log('numberss')
    }

    arrays<T>(this: Test<T[]>) {
        console.log('arrays')
    }

}

let ts = new Test<string>()
let tn = new Test<number>()
let ta = new Test<string[]>()

// Those work, as intended.
ts.strings()
tn.numbers()
ta.arrays()

// those don't
ts.numbers()
tn.strings()
ts.arrays()

/**
test.ts(30,1): error TS2684: The 'this' context of type 'Test<string>' is not assignable to method's 'this' of type 'Test<number>'.
  Type 'string' is not assignable to type 'number'.
test.ts(31,1): error TS2684: The 'this' context of type 'Test<number>' is not assignable to method's 'this' of type 'Test<string>'.
  Type 'number' is not assignable to type 'string'.
test.ts(32,1): error TS2684: The 'this' context of type 'Test<string>' is not assignable to method's 'this' of type 'Test<string[]>'.
  Type 'string' is not assignable to type 'string[]'.
*/

@niieani
Copy link

niieani commented Aug 26, 2016

@ceymard that's a pretty cool hack, but it still means you need to explicitly type all the generics in case a method has more than one generic type that we want to use. A more complex example, where this breaks apart:

class Test<T> {

    // note that if there is no member of type T, the errors are not spotted
    val: T

    strings<S extends string, X>(this: Test<S>) {
        console.log('strings !')
        return 1 as X
    }

    numbers<N extends number, X>(this: Test<N>) {
        console.log('numberss')
        return 1 as X
    }

}

let ts = new Test<string>()
let tn = new Test<number>()

// S and N could be inferred, but I X needs to be explicitly stated here, so I can't do this with good results:
ts.strings()
tn.numbers()

// I'd need to duplicate the typings of S and N, which beats the point of inferrence:
ts.strings<string, number>()
tn.numbers<number, number>()

It would be best if we could solve this problem without adding making the methods themselves generic.

@ceymard
Copy link

ceymard commented Aug 26, 2016

If you prefer, you can do that too ;

class Test<T> {

    // note that if there is no member of type T, the errors are not spotted
    val: T

    strings<X>(this: Test<string>) {
        console.log('strings !')
        return 1 as X
    }

    numbers<X>(this: Test<number>) {
        console.log('numberss')
        return 1 as X
    }

}

This still works IIRC, I don't know why I used the <... extends > as it is not mandatory.

Also, if you have a Test<MyType>, it will automatically accept anything that extends MyType as well.

I agree it would be best though, but for now that we have nothing, I think this is a "hack" that may help whoever is looking to do just that in 95% of their use case.

Doesn't mean this issue should be closed.

@niieani
Copy link

niieani commented Aug 26, 2016

OK, looks cool! Thanks. Sad you still need the val: T in there though.

@RyanCavanaugh
Copy link
Member

I think this parameters plus conditional types effectively solve the use cases here. We can take a new suggestion if there are more concrete scenarios that needed addressing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

8 participants