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

Add type declaration overloading #17636

Closed
SamPruden opened this issue Aug 6, 2017 · 56 comments
Closed

Add type declaration overloading #17636

SamPruden opened this issue Aug 6, 2017 · 56 comments
Labels
Suggestion An idea for TypeScript

Comments

@SamPruden
Copy link

SamPruden commented Aug 6, 2017

I'm keeping this relatively brief and loose for now as I haven't thought it through in excruciating detail yet, I may add detail later to flesh this out into more of a formal proposal.

There's a desire to have some form of type switching in the type system. I think #12424 is the main proposal that tackles this. This suggestion is yet another potential approach to that issue, as well as potentially adding some other little niceties to the type system.

If type declarations could be overloaded in much the same manner as functions, this would allow for type switching in a manner that's already familiar within the language. The rules would effectively be the same as that of functions.

  • All overloads must be directly adjacent so that they have order
  • The first matching signature is used to resolve the type
  • Overload signatures may have different lengths
  • If no signatures match, it's an error just as it is currently
type Foo<T extends number> = {aNumber: number};
type Foo<T extends boolean> = {aBoolean: boolean};
type Foo<T extends any> = {aDefault: T};

type FooNumber = Foo<number>; // = {aNumber: number};
type FooString = Foo<string>; // = {aDefault: string};

This idea was spawned in #17325, credit to @SimonMeskens for wanting to discuss it properly and pushing me to post it.

@SamPruden
Copy link
Author

Potential problems: Does this clash with declaration merging somewhere?

@aluanhaddad
Copy link
Contributor

aluanhaddad commented Aug 6, 2017

@TheOtherSamP

Potential problems: Does this clash with declaration merging somewhere?

I think so. Even if we assume modules, Module Augmentations would make ordering perplexing.

If you have

// vendor/api.d.ts

export type Foo<T extends number> = {aNumber: number};
export type Foo<T extends boolean> = {aNumber: boolean};

export type FooNumber = Foo<number>; // = {aNumber: number};
export type FooString = Foo<string>; // = {aDefault: string};

and then add

// src/augmentations.d.ts

export {}

declare module 'vendor/api' {
  type Foo<T extends any> = {aDefault: T};
}

then ordering could be violated causing any to take precedence and subsume the other constituents of the overloaded type.

I might be wrong on this...

@SamPruden
Copy link
Author

@aluanhaddad Hmm, why isn't this already a problem for function overloading? Actually, it seems like it is? I just threw a quick test together of the same thing with functions and it seems like it does add the overload, but it's not clear how the overload resolution is working.

Is there some reason this should be more of a problem than it already is with functions?

@aluanhaddad
Copy link
Contributor

@TheOtherSamP I am actually suggesting that overloaded functions suffer from the same issues. This proposal does seem very useful though.

@SamPruden
Copy link
Author

@aluanhaddad Oh right, okay. Yeah, it's interesting that that problem is pretty much baked into the language now then, I don't see that there's much that could be done to fix that without massive breaking changes. If that problem exists for functions already though, that means it would also exist doing this kind of type switching via #6606.

@KiaraGrouwstra
Copy link
Contributor

@TheOtherSamP asked me to give my view on how this compares to #6606, another proposal that would enable overloading, be it through type-level application of overloaded functions.

Going by my list in #6606 (comment):

overloading covers:

  • operate on boolean literals
  • unwrapping like promised
  • constraints, incl. (non-) union
  • type subtraction
  • type checking
  • checking index presence

not covered is function application:

  • angular factories
  • higher-order functions (compose, curry, bind, reduce, filter, tuple map, ...)
  • lenses

Going by my list in #16392:

  • type manipulation: ReturnType, BoolToString, Matches / TypesEq / InstanceOf, ObjectHasElem, TupleHasElem, conditional errors, pattern matching, constraints, ObjectHasStringIndex, ObjectHasNumberIndex.
  • function stuff: map over tuples / heterogeneous objects, reduce, filtering objects by predicate

tl;dr:

  • when we do actually need to do stuff with functions (because we get them in through params) we would, for better inference, need a way to deal with those. seemingly no way around 6606 for this one.
  • the other use-cases can pretty much be covered by an alternate methods of doing overloads like this as well, with a few (fixable) holes compared to 6606:

@SamPruden
Copy link
Author

Thanks @tycho01, that list is really great to have and makes a far better case for this than I could have on my own.

@KiaraGrouwstra
Copy link
Contributor

KiaraGrouwstra commented Aug 6, 2017

If that problem exists for functions already though, that means it would also exist doing this kind of type switching via #6606.

The existing declare function foo(v: string): bar; declare function foo(v: number): baz; style overloads would get their return types for given inputs calculated with #6606, yeah.

For the type-level use-cases utilizing it for overload-based type checks / pattern matching though, an type-level approach like interface foo { (v: string): bar; (v: number): baz; } would seem more idiomatic though, to prevent excessive switching between expression/type levels.

In that sense, I think the clashing issue is not so much aggravated by 6606, as it doesn't so much encourage repeating names.
But yeah, can't blame this proposal either if it was already an issue beforehand.

@SamPruden
Copy link
Author

For the type-level use-cases utilizing it for overload-based type checks / pattern matching though, an type-level approach like interface foo { (v: string): bar; (v: number): baz; } would seem more idiomatic though, to prevent excessive switching between expression/type levels.

Good point.

I suppose the argument could be made that we shouldn't add another place where the clashing issue manifests. I don't want to make that argument because I like this idea, but it does feel a bit wrong to put this in knowing that it's broken.

@KiaraGrouwstra
Copy link
Contributor

Erroring on clashes is an effective way to deal with accidental clashes, yeah. Not sure about nice workarounds there.
Then again, that risky scenario is already seems to be where we are for functions now, I guess?

So this proposal: if a generic input fails to match, find overloads. I presume this to mirror how function overloads work now, picking the first that matches by input requirement, rather than also continuing if say return type calculations explode.

If the foreign overload is broader than yours or goes first, it might catch (some of) your cases. If it catches all, that could warrant a "warning, given these equal requirements things could never drop through to the second declaration". Some would be harder to disambiguate.

@SamPruden
Copy link
Author

Very tired left-field thought: Could some operator (perhaps typeof) be applied to function types to generate matching (anonymous) type declarations? So rather than full #6606, typeof (<T>(o: T) => {item: T}) would become type <T> = {item: T}. I haven't really thought this through at all yet, just throwing it out really.

I'm thinking that it might be a way to avoid introducing the new syntax/concepts that #6606 is waiting on, and that overloaded type declarations should be capable of describing everything that function signatures can.

@SamPruden
Copy link
Author

SamPruden commented Aug 6, 2017

Although rest parameters might be a problem. I have a horrible feeling that may also need rest parameters in type declarations, and I'm not 100% sure what that would mean right now. It's also suddenly becoming a fairly hefty set of changes when you include that, but maybe that's acceptable when compared to the size of #6606 as it stands.

EDIT: Actually I think that wouldn't be an issue. I think you should only need a single type parameter for the rest parameter.

@aluanhaddad
Copy link
Contributor

@TheOtherSamP

Although rest parameters might be a problem. I have a horrible feeling that may also need rest parameters in type declarations, and I'm not 100% sure what that would mean right now.

That sounds a lot like the Variadic Kinds proposal #5453

@SamPruden
Copy link
Author

@aluanhaddad I think I was wrong there actually, that shouldn't be required. A single type parameter should be able to handle the rest parameter. I am, however, falling asleep and probably embarrassing myself by talking nonsense at this point, so who knows.

@KiaraGrouwstra
Copy link
Contributor

That sounds a lot like the Variadic Kinds proposal #5453

I think I was wrong there actually, that shouldn't be required. A single type parameter should be able to handle the rest parameter.

Pretty much, yeah, you can fake variadic generics by cramming stuff into tuples. They were suggested there anyway, though I'm with you we could probably do without. Say, (a: A, b: B, cs: number[]) could be called as a type as <[MyA, MyB, [MyC1, MyC1]]>.

Could some operator (perhaps typeof) be applied to function types to generate matching (anonymous) type declarations? So rather than full #6606, typeof (<T>(o: T) => {item: T}) would become type <T> = {item: T}.

That type <T> = {item: T} notation sounds like the anonymous / unapplied types add-on I mentioned.

I guess you could do it given this #17636 + those anynomous / unapplied types + 5453 (bonus if incl. variadic generics) + a way to extract params (#14400 / 6606) + some trick to get the proper return types (like 6606 yet not 6606), yeah.

the size of #6606

It's just ()! 😃

@SimonMeskens
Copy link

So there's a little awkwardness here in introducing syntax that needs to be in a certain place (all have to be adjacent or similar concerns). This is unavoidable due to how the proposal works. So I'm wondering if we couldn't propose a weaker version without these issues, that has equivalent power. What if we propose instead something like property overloading:

interface Fizz<T> {
  <T extends number>bar: {aNumber: number};
  <T extends boolean>bar: {aBoolean: boolean};
  <T extends any>bar: {aDefault: T};
}

This should be exactly equivalent to the proposal, if this also works:

type Foo<T> = Fizz<T>["bar"];

This looks somewhat more idiomatic, it alleviates the awkwardness, but doesn't fix the declaration merging issue (which is less of an issue as noted above).

@SamPruden
Copy link
Author

SamPruden commented Aug 6, 2017

@SimonMeskens

interface Fizz<T> {
  <T extends number>bar: {aNumber: number};
  <T extends boolean>bar: {aBoolean: boolean};
  <T extends any>bar: {aDefault: T};
}

Hmm, that's interesting. It seems to me, those properties aren't actually generic, they're just using the <T extends number> as a filter? My first instinct is that's a little uncomfortable because it's a different use for the same syntax, but I'm not sure.

more idiomatic

Personally, I'm not sure I agree. Type declarations feel like the right place to be manipulating types in this way, properties on interfaces like this, or functions in #6606, feel like a hacky abuse of a language feature.

For comparison, the type declaration equivalent of that interface would be:

type Foo<T extends number> = {aNumber: number};
type Foo<T extends boolean> = {aBoolean: boolean};
type Foo<T> = {aDefault: T};

interface Fizz<T> {
    bar: Foo<T>;
}

@SamPruden
Copy link
Author

@tycho01

(a: A, b: B, cs: number[]) could be called as a type as <[MyA, MyB, [MyC1, MyC1]]>

In the case of rest parameters typeof ( <T>(...args: T) => T[] ), I was thinking the type argument would just still be T, so the produced type would be type <T> = T[]. The tuple is interesting, but I can't quite get my head around exactly how that would work with varying numbers of arguments. Why [MyC1, MyC2] rather than MyC1 | MyC2?

That type = {item: T} notation sounds like the anonymous / unapplied types add-on I mentioned.

Yep, I pretty much stole that from you. I'm shameless.

the size of #6606

It's just ()! 😃

Yep! I actually meant that we're stacking up quite a few proposals at once here, but maybe the total burden of these is still not too large compared to the burden of doing #6606 instead.

What I like about this, is that it can actually be done nicely in parts. Compared to #6606, this could be nice as we might not have to wait for every part of this to happen before we can start enjoying some of the benefits.

  1. Just this Add type declaration overloading #17636 would add a lot of cool stuff on its own.
  2. Adding some anonymous type declaration syntax after that would then allow this to be a little neater as you'd gain the ability to inline things.
  3. Once you have anonymous type declarations, adding a conversion from function types to anonymous type declarations would get you all the features of Proposal: Get the type of any expression with typeof #6606.
  4. Optional Generic Type Inference #14400, Proposal: Variadic Kinds -- Give specific types to variadic functions #5453, etc would then just stack nicely and add a bit more power

@SamPruden
Copy link
Author

Regarding anonymous types (which might need their own issue soon), what about something like this?

type TypeWithInlineStuff<T> = {value: (
    type <U extends number> = {aNumber: number};
    type <U> = {aDefault: U};
)<T>};

I was primarily suggesting them to allow for the function type conversion, but it would be nice if they could also solve the inlining problem like this too.

@KiaraGrouwstra
Copy link
Contributor

interface Fizz<T> {
  <T extends number>bar: {aNumber: number};
  <T extends boolean>bar: {aBoolean: boolean};
  <T extends any>bar: {aDefault: T};
}

In my interpretation when you do <T extends number> that'd currently create a new generic, with the same name yet distinct from that top-level T. afaik properties (aside from functions) currently couldn't have generics either.

In the case of rest parameters typeof ( <T>(...args: T) => T[] ), I was thinking the type argument would just still be T, so the produced type would be type <T> = T[].

Heh, I'd been interpreting things such that the whole type array would be captured into T this way, but yeah whatever, doesn't matter much here.

Why [MyC1, MyC2] rather than MyC1 | MyC2?

In the first place because I know how to iterate over tuples types so as to do stuff with them, but don't see a straight-forward way using union types.
I want union iteration myself as well, but don't know of / haven't made proposals so far; not sure what'd be most elegant. I mentioned it a few times in #16392; best thing I could come up with was proposing some union-to-tuple operator. Open to ideas.

what about something like this?

Well, I certainly hadn't thought of that. 😅

@SamPruden
Copy link
Author

Heh, I'd been interpreting things such that the whole type array would be captured into T this way, but yeah whatever, doesn't matter much here.

I'm still not quite getting how this would work, as the function isn't being called so we don't have the arguments. I may be being thick. I think this is a relatively unimportant detail of a side issue for now though, probably don't need to dwell on it yet.

Are we ready for a separate issue for this stuff? Maybe even two, one for anonymous type declarations, one for function types -> anonymous type declarations? I could go ahead and make those, I'm not sure if that's neater than keeping that discussion here, or prematurely fractures the discussion.

@KiaraGrouwstra
Copy link
Contributor

T

I'd just reason (...args: any[]) -> (...args: T) -> T must be some array. But yeah whatever.

Are we ready for a separate issue for this stuff?

Works for me, if you mention thread numbers here I'll subscribe.

@aluanhaddad
Copy link
Contributor

I'm still largely of the mind that iterating over tuples is an unpleasant consequence of their concrete manifestation as (H)Arrays in JavaScript and how that has to be interpreted by TypeScript in order to make any sense at all out of the intended use patterns of API's like Object.entries which, while useful, are poorly designed even in the context of a purely dynamic language.

For example, the shape of Object.entries seems intended and optimized for concise consumption with implicit renaming as in

for (const [key, registration] of Object.entries(registry)) {
  yield {key, registration};
}

But I think the pattern is degenerate, that Object.entries should return an Iterable<{key, value}>.
After all, how much harder is it to write

for (const {key, value: registration} of Object.entries(registry)) {
  yield {key, registration};
}

Of course I'm speaking entirely about value level and not type level constructs but I think that the former has corrupted the latter such that we wish to iterate over tuples and can do so in a roughly typed manner as a consequence of TS having to model standard JS APIs that are awkwardly designed.

@KiaraGrouwstra
Copy link
Contributor

@aluanhaddad: I'll grant you that.

Reminds me a bit of a recent quote from gcanti/typelevel-ts#8 (comment):

A very large part of the useful abstractions in TypeScript end up being hacky mapped types.

Hopefully at one point we'd have pretty solutions for everything.

On tuple iteration specifically, my initial use-case for it was Ramda's path. At the time, even with the silly type Inc = [1, 2, 3]; // ... thing, that hacky construct to iterate over them just felt like a nicer idea than using 1000-line overloads for inferior functionality (no tuple support) and terrible performance.
That still doesn't work in functions, but yeah.

If tomorrow the most powerful constructs to type things are different from today, then yay, progress.

@SamPruden
Copy link
Author

SamPruden commented Aug 6, 2017

I'm working on new issues for anonymous type declarations and function types -> anonymous type declarations now.

Just a thought though, while this gets us type switching, we don't quite have type filtering here just yet. How would we selectively map over a type with this? Should a type with no matches map to nothing in a mapped type?

interface Foo {
    name: string;
    age: number;
    isEnabled: boolean;
}

type StringNumberMapper<T extends string | number> = T;
type Bar = { [K in keyof Foo]: StringNumberMapper<Foo[K]> }; // = { name: string; age: number; }

@KiaraGrouwstra
Copy link
Contributor

Like { [K in keyof Foo]: If<Matches<T[K], string | number>, T[K], never> }, nevers get pruned automatically.

@SamPruden
Copy link
Author

Like { [K in keyof Foo]: If<Matches<T[K], string | number>, T[K], never> }, nevers get pruned automatically.

Are you saying that's already in the language? I wasn't aware of that, and a quick test failed to recreate it.

If you're suggesting that be added, I think I like that. The only question I'd have is whether you'd ever legitimately want to produce an unpruned never, which this would seemingly prevent.

@KiaraGrouwstra
Copy link
Contributor

@TheOtherSamP:

Are you saying that's already in the language? I wasn't aware of that, and a quick test failed to recreate it.

I seem to have misremembered; I guess we made objects containing keys or never, use keyof to just get the keys without never, then plug the keys back into the original object so as to filter it. Source #13470 (comment).

I guess that'd make it something like this instead:

type ObjectValsToUnion<O> = O[keyof O];
Pick<Foo, ObjectValsToUnion<{ [K in keyof Foo]: If<Matches<T[K], string | number>, K, never> }>>

Dunno if that could be further simplified.

I think the scenario where identically named types are accidentally placed next to each other, forming overloads when you'd want an error, are pretty rare though.

That's a fair point; so far I hadn't considered it should only consider them overloads if next to one another. It might give a bit of extra complexity for implementation, but it does address the concern.

@SamPruden
Copy link
Author

That's a fair point; so far I hadn't considered it should only consider them overloads if next to one another. It might give a bit of extra complexity for implementation, but it does address the concern.

Oh, apologies, I thought that was clear. I was imagining adjacency would be required in the same way it is for function overloads. It's not quite the same thing, but it seemed analogous. That doesn't actually eliminate the merging problem though, as with functions those still become overloads.

Dunno if that could be further simplified.

I think if we can make this syntax nicely handle filtering without having to do all that, that would be great. That stuff is fine for power-users, but not exactly approachable to someone just getting started. This feels like it might be an opportunity to add filtering really smoothly, though I'm not sure how.

Of course the other option is to just leave that alone and wait on some [K in keyof T if ...] feature to be added later, and use your more hardcore methods in the meantime.

@KiaraGrouwstra
Copy link
Contributor

not exactly approachable

Yeah, the mainstream approach is just Partial<Foo>.

I think if we can make this syntax nicely handle filtering without having to do all that, that would be great.

Well, this is why I started a type library, so people wouldn't need to reinvent the wheel.

Potential abstractions given language add-ons:

type ObjectValsToUnion<O> = O[keyof O];
// 6606
type Filter<T, Cond> = Pick<T, ObjectValsToUnion<{ [K in keyof Foo]: If<Cond(T[K]), K, never> }>>;
type Bar = Filter<Foo, isT<string | number>>;
// anonymous types, syntax made up on the spot, not necessarily overload-friendly
type Filter<T, Cond> = Pick<T, ObjectValsToUnion<{ [K in keyof Foo]: If<Cond<T[K]>, K, never> }>>;
type Bar = Filter<Foo, <V> => Matches<V, string | number>>;

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Aug 7, 2017

it doesn't seem that anyone noticed that currently there is NO SPECIFIED ORDER in which type declarations from different files are applied, say you have x/a.d.ts and y/a.d.ts so which one do you think will take precedence?

or you have

// a.d.ts
declare global {
   type T = whatever;
}

and then somewhere else

// a.d.ts
declare global {
   type T = meh;
}

@masaeedu
Copy link
Contributor

masaeedu commented Aug 7, 2017

@Aleksey-Bykov Relevant discussion here.

@zpdDG4gta8XKpMCd
Copy link

there is a standing problem of making function/method overloads from different definitions work consistently, i don't think this proposal will get anywhere before that problem is fixed

@SimonMeskens
Copy link

@Aleksey-Bykov Except this thread already contains potential fixes and workarounds for exactly that issue?

@SamPruden
Copy link
Author

While we'd talked about this already existing for methods here, somehow we hadn't noticed that this problem already exists for type declarations too, so thanks for pointing that out @Aleksey-Bykov. Actually though, my assumption is that that might make it less of an issue for this proposal. If type declarations are already broken in that manner, this proposal really doesn't make things any worse than they already are. The only way I can see it being a blocking issue for this is if you think we shouldn't do anything with type declarations at all until it's fixed.

Or, I suppose, if you think having to work around the existence of overloads could make the merging issue harder to solve. However that's a problem that already needs to be solved for functions, so I don't see that that could be too much of an issue.

@SamPruden
Copy link
Author

SamPruden commented Aug 7, 2017

Random just-woke-up thought regarding filtering, what if we introduced another special-case type like never? Working title, vanish.

A property of type vanish would, well, vanish. It wouldn't exist. So Foo and Bar are identical here.

interface Foo {
    foo: number;
    bar: vanish;
}

interface Bar {
    foo: number;
}

Such that a filter can simply map a property to type vanish to remove it.

Initially I'm thinking vanish | T -> vanish, vanish & T -> T, so the opposite of never. A type with these semantics might also be nice for the more advanced typing tricks, though that's a little besides the point.

@masaeedu
Copy link
Contributor

masaeedu commented Aug 7, 2017

@TheOtherSamP There is an issue tracking subtraction types in a different place, I think that is more of a cross-cutting concern in the language. Doesn't seem particularly relevant to this issue.

@SamPruden
Copy link
Author

@masaeedu Well, the (admittedly weak) relevance to this issue is that it might play nicely with the type of type switching this enables. For example:

type FunctionFilter<T extends Function> = vanish;
type FunctionFilter<T> = T;

type NonFunctionMembers<T> = {[K in keyof T]: FunctionFilter<T[K]>};

It's not immediately apparent to me how the same thing could nicely be implemented with subtraction types.

@masaeedu
Copy link
Contributor

masaeedu commented Aug 7, 2017

It seems your vanish would just be T - T with subtraction types, unless I'm misunderstanding something.

@SamPruden
Copy link
Author

@masaeedu Ahh, I think I probably explained it badly, sorry. vanish would completely remove the property. It's a bit weird I know, but a property of type vanish just wouldn't exist.

@masaeedu
Copy link
Contributor

masaeedu commented Aug 7, 2017

I misremembered how subtraction types were proposed (see #4183). I was assuming T - T would be never, which is a type that admits no values (and hence seems like it would be identical to vanish). Apparently not though.

@SamPruden
Copy link
Author

@masaeedu never isn't identical to vanish.

type Foo = keyof {bar: never, fizz: "bang"}; // = "bar " | "fizz"
type Foo = keyof {bar: vanish, fizz: "bang"}; // = "fizz"

I was thinking about this a bit last night, I think I'm going to write up a gist with my thoughts on the topic of type filtering because I want to get my thoughts down on (digital) paper, but I don't want to derail this thread too much. I'll link that here if I get it done.

@SamPruden
Copy link
Author

@masaeedu I got a little carried away with thinking about the filtering, the gist is here. I don't want to put up yet another issue just yet (I feel I've spammed them a little recently) but I may put that up for discussion at some point. If anybody particularly wanted to discuss anything there now I could be persuaded to make it into an issue.

I feel that topic is tangentially related to this one as a lot of the discussion relies on type declaration overloading, but it's not exactly a discussion about type declaration overloading. I do think it demonstrates some of the power of TDO though.

@SamPruden
Copy link
Author

That gist is now an issue at #17678 because I have no chill.

@mhegazy mhegazy added the Suggestion An idea for TypeScript label Aug 22, 2017
@KiaraGrouwstra
Copy link
Contributor

I finally got a PoC for the overload pattern matching working now at #17961. So that's using type level function application, but feel free to try if interested.

@laughinghan
Copy link

Isn't this resolved by #21316 ?

@KiaraGrouwstra
Copy link
Contributor

@laughinghan yeah, seems like it.

@mhegazy mhegazy closed this as completed May 29, 2018
@microsoft microsoft locked and limited conversation to collaborators Jul 31, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

8 participants