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

Proposal: Partial Type Argument Inference #26242

Open
weswigham opened this issue Aug 6, 2018 · 112 comments
Open

Proposal: Partial Type Argument Inference #26242

weswigham opened this issue Aug 6, 2018 · 112 comments
Assignees
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

@weswigham
Copy link
Member

weswigham commented Aug 6, 2018

After exploring the concept in #23696, we've come to the conclusion that implicitly making type arguments available by name would unnecessarily expose previously unobservable implementation details. As such, I'll be breaking that proposal down into two parts. One is just the partial inference (which is here), which can stand on its own, as discussed in #20122 and #10571.

To recap, partial type argument inference is the idea that some number of type arguments in a type argument list may be provided, while others are elided and fulfilled via inference using the rest. Specifically, in #10571 a number of different syntax proposals will brought forward:

For the following examples, assume we have a

declare foo<A,B,C>(): [A, B, C];

Variant 1 - elided entries

foo<,,string>(); // returns [{}, {}, string]

This is the most terse option, as simply the lack of inputs implies inference sites. This would almost seem to logically follow from how not providing a list also causes inference to occur at all sites. This does have issues, however: specifying a final parameter as needing inference would require a trailing , in the list (something we currently explicitly disallow), and lists could very easily become confusing, as a , is very easy to skip over.

Variant 2 - Sigil marker

foo<*, *, string>(); // returns [{}, {}, string]

As the second most terse option, this also has appeal; however I think it also fails on a few points. First, * is non-obvious what it means; it implies a "wildcard" of some kind, but in the context of types that could mean an inferred type, a bound, or an existential. Second, as a single-character sigil, we're unlikely to meaningfully provide completions for it even though it is contextually relevant. Finally, we're considering other work to do with existentials and generated type parameters in the future which we'd like to be able to use the * as an indicator for.

Variant 3 - Keyword marker

a. auto

foo<auto, auto, string>(); // returns [{}, {}, string]

b. infer

foo<infer, infer, string>(); // returns [{}, {}, string]

Neither of these are as terse as the others, but both are still likely substantially shorter than providing the entire list of types by hand in situations where partial inference is desired. Of the two keywords, auto may be shorter, but currently carries no meaning within the language. infer on the other hand is already used for marking inference positions within conditional types. The infer method was explored in #22368, however was taken much father - almost fully replicating the arbitrary placement and naming the operator affords within conditional types.

In the end, I'm advocating for variant 3b - the infer placeholder, with none of the extra features afforded in #22368 (we can always add them later if there is demand).

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Aug 6, 2018
@treybrisbane
Copy link

My vote is for either 1 or 3b :)

@AlCalzone
Copy link
Contributor

Maybe adopt the ? from the partial application proposal

foo<?, ?, string>(); // returns [{}, {}, string]

which already stands for optional in TypeScript. Actually, the meaning of would be pretty similar to the proposal and could allow for something like this:

type Foo<T, U, V> = T | U | V;
type Bar = Foo<?, string, ?>; // equal to type Bar<A, B> = A | string | B;

@jsiwhitehead
Copy link

Will this include the option for simply omitting trailing type arguments, and having them automatically set as inferred?

Ie for these to be equivalent:

foo<string, infer, infer>();

foo<string>();

@weswigham
Copy link
Member Author

@jsiwhitehead consider:

declare const foo: {
  <A, B>(): A & B;
  <A>(): A;
};
foo<string>(); // The signature this refers to would be ambiguous if `infer`s were autofilled

So I don't think so - those likely won't be equivalent.

@jsiwhitehead
Copy link

@weswigham sorry I wasn't fully clear, I meant in the case when the trailing type arguments are optional / have defaults, so in situations where they can already be left out.

The change I'm asking about is how the unprovided optional types are resolved. Currently there are two different cases:

  • No types are provided explicitly => all types are inferred
  • At least one type is provided explicitly => no inference, all unprovided types just take their default

Hopefully this proposal for partial inference could include allowing inference to continue working in the second case (as requested in #19205, which is closed as a duplicate of #10571).

E.g.

declare function foo<A, B = any>(b: B): [A, B];

foo<number>(null); // Resolves to [number, any], should resolve to [number, null]

Or does that also lead to new ambiguities?

@weswigham
Copy link
Member Author

Or does that also lead to new ambiguities

In your example no, for more complex types with multiple signatures, yes. It's also technically a break to do that since we'd suddenly be doing inference where previously people we relying on defaults. By making it explicit with foo<number, infer>(null) we avoid both issues.

@jsiwhitehead
Copy link

I'm struggling to see how this could lead to an ambiguity that wasn't already there sorry, even with multiple signatures. The change I'm asking about is only about how unprovided type arguments are resolved, not about the process Typescript uses to choose which signature to use.

E.g. there is an ambiguity in the following, but that is the case already, and currently is just resolved by taking the first possible match, which is fine.

declare const foo: {
  <A, B = any>(b: B): [A, B];
  <A>(b: any): A;
};
foo<string>(null); // This resolves to [string, any], but should resolve to [string, null]

Sorry to keep digging on this, but I'm trying to type a selector based API which will be vastly less usable if infer has to be written for all types:

do<string>('key1', 'key2', ..., 'keyN', (value1, value2, ..., valueN) => ...)

vs

do<string, infer, infer, ..., infer (N times)>('key1', 'key2', ..., 'keyN', (value1, value2, ..., valueN) => ...)

Of course, if the fact that this would technically be a break means it's a no go either way, then that's just how it is!

@sledorze
Copy link

sledorze commented Aug 11, 2018

@weswigham We are wondering how this feature would play with a new encoding we're likely to use in fp-ts.

Given:

right: <L = never, A = 'reason is you cannot partially bind Type Params to `right`'>(a: A) => Either<L, typeof a>

What would be the typing for:

// x3: Either<string, number> or Either<string, ???>
const x3 = right<string, infer>(1)

The thread is discussed here:
gcanti/fp-ts#543 (comment)

Thanks in advance.

@opiation
Copy link

Not sure if I'm a little late to the game here but I'd like to give a simple use case for when this might be be useful.

Given the following:

interface Options<S = {}, Payloads = {}> {
  reducers: {
    [key in keyof Payloads]: (state: S, payload: Payloads[key]) => S
  }
  state: S
}

function identity<S, Payloads = {}>
  (options: Options<S, Payloads>): Options<S, Payloads> {
  return options
}

const options = {
  reducers: {
    add: (state, payload: number) => ({
      ...state,
      answer: state.answer + payload
    })
  },
  state: {
    answer: 42
  }
}

Type inference works wonderfully as expected when no type arguments are supplied to the identity function...

// Both State and ReducerPayloads are inferred correctly provider `state` and `payload` type safety
const fullyInferred = identity(options)

When one explicitly types the S however, type inference for Payloads is lost and defaults to {} despite the inferable type information for Payloads being more specific and arguably safer to use.

// When explicitly specifying the State however, ReducerPayloads is no longer inferred and
// defaults to {}.  We effectively lose type inference for `partiallyInferred.reducers`
const partiallyInferred = identity<{ answer: number }>(options)

Using infer here would allow the API consumer to specify when the inferable type should be used in place of the default.

const partiallyInferred = identity<{ answer: number }, infer>(options)

If there's already a means of achieving this partial inference in this example, feel free to share it here as it would seem quite useful.

@insidewhy
Copy link

insidewhy commented Aug 31, 2018

@jsiwhitehead I feel your pain, I've been writing an API that uses a type involving many generic string literals (they are used to build action creators for ngrx). It's annoying having to iterate every argument even with the version of typescript from this branch.

I wonder if maybe a trailing ** could ease our pain. So instead of needing:

<T, *, *, *, *, *>() => {}

where the last 5 here are string literals and I need to add a new * everytime I need to add a new string literal to my class, I could write:

<T, **>() => {}

to infer all subsequent generics

@flushentitypacket
Copy link

@ohjames I like that. Further, that:

<T>() => {}

is equivalent to

<T, **>() => {}

@insidewhy
Copy link

@flushentitypacket Yes, I wish the language was designed that way in the first place, however now it's too late to implement things that way, given it will conflict with default generic parameters?

@aleclarson
Copy link

aleclarson commented Sep 27, 2018

I wonder if maybe a trailing ** could ease our pain.
<T, *, *, *, *, *>() => {} => <T, **>() => {}

Maybe, a trailing "elided entry" could imply all remaining parameters are inferred.

<T,>() => {}

edit: Actually, this would be an issue for multi-line parameters.

@falsandtru
Copy link
Contributor

related: #21984

@lukescott
Copy link

lukescott commented Dec 17, 2018

Could you simply make trailing types optional, while making leading types required? It would fully depend on how the types were ordered, but I see that as a feature rather than a limitation. Optional function arguments work similarly.

Given:

declare foo<A,B,C>(): [A, B, C];

This means you can do any of these:

foo()
foo<string>()
foo<string,string>()
foo<string,string,string>()

But not:

foo<,string>()
foo<,,string>()
foo<,string,string>()
foo<,string,>()

This wouldn't require any special syntax parsing. It would simply fix the expected arguments error.

@insidewhy
Copy link

@lukescott That proposal is here: #10571
It's also how type parameters work in C++. I think TypeScript probably inherited this limitation from C#. Looks like it's being fixed in both languages, will probably land in C# first.

@lukescott
Copy link

lukescott commented Dec 17, 2018

@ohjames I do see omitting trailing types mentioned in #10571, but it looks like it advocates for an auto keyword, which would open the door to foo<auto,string>(). Later comments in the issue mention _ and *. I'm not sure I see much of a difference. IMO, leading types should be required. Even with that, making trailing types optional solves a lot of use-cases.

@insidewhy
Copy link

insidewhy commented Dec 17, 2018

@lukescott read the initial comment on that issue, the talk of * etc. is tangential to the issue.

Edit: Maybe it is lacking a bit of focus. If there isn't an issue for simply omitting trailing types then maybe someone should open one. Might it conflict with type parameter defaults though?

@lukescott
Copy link

lukescott commented Dec 17, 2018

@ohjames

Maybe it is lacking a bit of focus. If there isn't an issue for simply omitting trailing types then maybe someone should open one.

That was my thinking as well. There is a lot of overlap between each of these proposals, with similar thoughts being shared. I haven't seen any mention to rein this into an MVP. IMO, the current proposals are too broad.

Might it conflict with type parameter defaults though?

I'm not sure how. At least any more than the current proposals would. Requiring leading types is more restrictive. Default types are also restrictive:

function foo<A,B = string,C>(a: A, b: B, c: C) {}
// error on C: Required type parameters may not follow optional type parameters.

Do you have something in mind?

@insidewhy
Copy link

insidewhy commented Dec 18, 2018

@lukescott I think it's something like:

function totoro<A, B = object>(a: A, b: B) { ... }

Currently if I call this function thusly:

totoro('friend', new Date(2018, 3, 19))

Inside of totoro the B will still be of type object. However if we allow omitting parameters, in this case B could be inferred as Date. That would then make this a backwards incompatible change. Honestly I think it'd be better to do it like C++, and have it only fallback to the default type when inference is not possible; but for the sake of not breaking backwards compatibility this level of inference could be restricted to type parameters that do not specify defaults.

@lukescott
Copy link

lukescott commented Dec 18, 2018

@ohjames
I think what you're saying is:

function totoro<A, B = object>(a: A, b: B): [A,B] {
	return [a,b]
}

const result1 = totoro('friend', new Date(2018, 3, 19))
const result2 = totoro<string>('friend', new Date(2018, 3, 19))

result1 comes back with [string, Date] and result2 comes back with [string, object]. Inside the function is {} unless you do B extends object = object.

It would seem like either proposal has the same effect on this. In either case you could either change how it works and infer Date, or use object as it currently works.

Personally I would prefer to break compatibility here and use default only when the type can not be inferred.

@insidewhy
Copy link

@lukescott Thanks for fixing my example. Glad you agree with the compatibility break but not sure if others will see it the same as us. It's been bothering me ever since C# adopted this limitation. Feel like it takes a lot away without bringing anything.

@Richiban
Copy link

Can't we support a {type} = {Expression} syntax at the call site?

I have a feeling that f<,,,,,,string,,,,,>() will be simply impossible to read with more than three type parameters.

My syntax would allow for supplying just the single type parameter that is causing a complete lack of type inference. For example, when calling the below function f the type D cannot be inferred, but the others can:

function f<A, B, C, D>(a : A, b : B, c : C) : D {
    ...
}

I suggest making it possible to call this with the syntax:

var d = f<D = string>(1, 2, 3);

@ExE-Boss
Copy link
Contributor

I would also like to be able to omit all inferred types when unambiguous:

interface SpecialArray<T> extends Array<T> {}

let a: SpecialArray<_> = [1]
// and
let b: SpecialArray<> = [1]
// are both equivalent to:
let c: SpecialArray<number> = [1];

@RyanCavanaugh RyanCavanaugh added the Needs Proposal This issue needs a plan that clarifies the finer details of how it could be implemented. label Feb 25, 2019
@JesusTheHun
Copy link

@weswigham Is there anything we can work on to make the study of this proposal easier for the team ?
Are there questions the team have that need to be answered, or some cases they would like to see laid out and its behavior described ?

@Flamenco
Copy link

Flamenco commented Apr 6, 2024

It this hold up due to a limitation of the language, the lack of an implementation, or the indecisiveness of the syntax?

@tomerh2001
Copy link

Any update?

@jcalz
Copy link
Contributor

jcalz commented May 10, 2024

https://github.com/microsoft/TypeScript/wiki/FAQ#any-updates (I'll delete this comment if you delete yours, @tomerh2001 )

@tylerlaprade
Copy link

@jcalz, in a perfect world I'd agree, but many repos have a bot that will auto-close issues after a certain period of inactivity, so developers have been trained to periodically bump issues they care about to ensure they stay open.

@MunMunMiao
Copy link

I would like to see the implementation plan or alternative solution for this proposal, which will undoubtedly bring huge benefits to developers.

@MunMunMiao
Copy link

I proposed a keyword generic reference, which might solve the problem more elegantly.

#59126

@motherthestate
Copy link

Was grouping generics as sets every discussed, not viable? Single sets behavior would remain unchanged, otherwise opt in.

const declare = <V,><D,>(value: V, descriptor: D) => {
  return { value, descriptor }
}

declare<number>(12, "Number" as const) // { value: number; descriptor: "Number" }

@essenmitsosse
Copy link

essenmitsosse commented Jul 5, 2024

@motherthestate, I think the idea looks intriguing at first. You could call this Generic Currying. You can "kind of" achieve that already if you curry the function itself:

const declare = <V>(value: V) => 
    <D>(descriptor: D) => 
        ({ value, descriptor })

declare<number>(12)("Number" as const)

This works surprisingly well. Is this great? Probably not.

But it allows you to get a feel for how this would work. Even if just currying the generics would be a feature, it would have a similar drawback: You would have to know whether the arguments are curried. This would only have the benefit over runtime currying, that it wouldn't need to curry the runtime — which honestly for many cases wouldn't matter that much. The benefit would be rather slim, given the added complexity.

@bluepnume
Copy link

Would be great to reconsider allowing specifying type parameters by name.

Otherwise functions with complex type parameters have exactly the same problem as functions with complex runtime parameters:

  • They're hard to refactor
  • They're not very self-documenting
  • They all need to be explicitly passed (or in this proposal marked as infer or something) even if the caller is perfectly happy with the default

We don't encourage writing functions like a(b, c, d, e, f, g) so why should we encourage generics like A<B, C, D, E, F, G>?

@luckydonald
Copy link

luckydonald commented Sep 4, 2024

I feel like 3b is a great one, but also that "missing" ones should be filled in with infer automatically.

So <number, infer, infer> can be written as <number>, too.

Basically where TS2558: Expected 3 type arguments, but got 1., happens, filling them with infers. If it then can't infer stuff, that can be a new error message.


A different idea would be to have an Infer<…> part of the definition side.

const someFunc = <OBJECT, Infer<KEY extends keyof OBJECT>>(...)

It that case it wouldn't even accept anything for that position, and skip it entirely.

@JesusTheHun
Copy link

JesusTheHun commented Sep 4, 2024

After thoughts digest

I've been reading, thinking and experimenting quite a bit on this situation now.
I thought I would share a subjective view on all of this.

Purpose

Many things have been said, proposals have emerged and it's hard to keep up with what have been deemed impractical or invalid.

This aims to offer a walkthrough of a valid, convenient and safe proposal.
I also want to provide an exhaustive listing of the cases that have been considered, more on that later.

The requirement

Be able to infer type parameters even if the consumer passes some type parameters

Walkthrough

declare function get<Doc, Key extends keyof Doc>(doc: Doc, key: Key): Doc[Key];

We could consider that in the context of a function call, every generic argument is optional. And therefore the following would be valid :

declare var obj: unknown;
const value = get<{ title: string }>(obj, 'title'); // string

Doc is set by the consumer and since Key is omitted, it is inferred.

However, the fact that TS emits an error for the missing type argument is a valuable information and I don't think we should assume the consumer will not forget a type argument they actually want.
So we need to mark a type argument as optional, which could be done with the keyword optional or a ? after the type name. It's just an example, don't get stuck on this :

declare function get<Doc, optional Key extends keyof Doc>(doc: Doc, key: Key): Doc[Key];
const value = get<{ title: string }>(obj, 'title'); // string

But wait, I have several question already !

Why not use a default type argument ?

declare function get<Doc extends { title: string }, Key extends keyof Doc = 'title'>(doc: Doc, key: Key): Doc[Key];

declare let obj: any;

// ❌ TS2345: Argument of type 'foo' is not assignable to parameter of type 'title'
const value = get<{ title: string; description: string }>(obj, 'foo'); 

As you can see, using a default type argument set the expected type and therefore the consumer must comply to that default type. So we can't set a default to achieve our goal.

What about multiple signatures ?

In the current state of TS, the signature is picked based on the consumer call.
This behavior remains unchanged. The fact that a type is optional or has a default value does not affect how a signature is picked over another.

Is it any different than other suggestions for infer ?

declare function get<Doc, infer Key extends keyof Doc>(doc: Doc, key: Key): Doc[Key];
  1. The very word infer creates an ambiguity :

Will the type be always inferred ?

What if the consumer passes the type themselves ? Is it ignored then ?

Using an optional type does not raise such ambiguity, the only thing to know is that if the type is not provided by the consumer, it will be inferred. Which is the intuitive expectation.

  1. infer does not induce optionality

This is one of the things that lead to the suggestion of sigils on the consumer side (which I find to be true evil).

Requirements for proposals

For anyone who is interested in submitting a new proposal, here are my very subjective requirements for any proposal :

  1. MUST not break backward compatibility
  2. MUST be set in the declaration, not by the consumer
  3. MUST induce optionality
  4. SHOULD NOT not create ambiguity on the behaviour when the type is set and when it's not
  5. MUST not conflict with argument types defaults

Restrictions of our proposal at this stage

Required type parameters must not follow optional type parameters. Just like type parameters with default.

A type argument marked optional cannot have a default.

Digest

Hold on. If an optional type argument cannot have a default and is inferred when it's not set by the consumer, how is it different than saying that the default value is the inferred type of the argument ?
It is not different.

That's why I told you to not get stuck on the use of the optional keyword. We can fulfill our requirement by using the keyword infer as the type argument default :

declare function get<Doc, Key extends keyof Doc = infer>(doc: Doc, key: Key): Doc[Key];

const value = get<{ title: string }>(obj, 'title'); // string

Keeping track of every cases

Because it's difficult to keep track of how every case is handled in a proposal, I've created a gist where I will be adding new cases as they come up to make sure everything is clear for the team if they decide to implement this feature.

Link to the gist : https://gist.github.com/JesusTheHun/935569ed4962cca3ef7b23d035c96e2f

If your case is not covered in the gist, please add comment in the gist and I will make sure it's covered by my proposal and update the gist.

I will also update the following section to mirror the content of the gist.

declare function get<Doc, Key extends keyof Doc = infer>();
declare let unknownObj: unknown;
declare let obj: { title: string; description: string };

// CASE 0
const case0 = get(obj, 'description'); 
// Unchanged: string

// CASE 1
const case1 = get<{ title: string; description: string }>(unknownObj, 'description'); 
// Current: TS Error
// Proposal: string

// CASE 2
const case2 = get<{ title: string; description: string }, 'title'>(unknownObj, 'description');
// Current: ❌ missing type argument
// Proposal: ❌ 'description' cannot be assigned to 'title'

// CASE 3
// Currently, you have to write
declare function floatingGenericTypeArgument<A, B = any>(b: B): [A, B]

// With this proposal, you could write
declare function floatingGenericTypeArgument<A, B = infer>(b: B): [A, B]

const case3 = floatingGenericTypeArgument<'a'>(null);
// Current: ['a', any]
// Proposal: ['a', null]

Conclusion

I hope this walkthrough was useful, if so, please add a mention so it doesn't feel like I've wasted 2.5 hours 😄

To the TS team, I hope this is helpful and help you sort out what's good and bad and make the study of the requested feature easier.

@lazytype
Copy link

Since my quick ctrl+f (after expanding the 100+ comments) did not show that this has been suggested before. It would make sense to me to be able to explicitly choose the default type parameter type using the default keyword, e.g. foo<default, default, string>(). This would parallel passing in undefined as an argument to a function parameter with a default value in vanilla JS. With this affordance, one would be able to specify exactly what they intend, e.g. foo<infer, default, infer, string>.

As suggested by someone before, being able to do foo<string, ...infer>() could be nice while others have suggested breaking backwards compat and make foo<string>() change behavior to be equivalent to foo<string, ..infer>(). In a world where such breakage occurs, one could imagine being able to do foo<string, ...default>() to get the previous behavior.

@JesusTheHun
Copy link

@lazytype what problem does this solve ? It's already possible to have the same behaviour by passing never for example, and the type definition uses the default value to handle such case.

@shicks
Copy link
Contributor

shicks commented Sep 20, 2024

@JesusTheHun can you clarify where you're passing never? IIUC, Foo<string, never> is not the same thing as Foo<string> unless the default was explicitly defaulted as to Foo<T, U=never>.

One problem default solves is being able to opt into a default for a parameter while specifying a later parameter. It's an interesting idea, though it comes with the downside of breaking a pattern I've used to force a default (e.g. to work around #7061):

type Foo<T, Force = Private extends Private, Alias = ComplexExpressionOver<T>> =
    [Force] extends [null] ? {error: '...'} : OtherExpressionOver<Alias>;

which defensively prevents the user from ever passing an incorrect value for Alias since they can't provide a valid Force if they don't have access to Private (any and never will both trigger the error branch). But this is less relevant if we had a solution to the other issue. With @lazytype 's proposal, you could circumvent this with Foo<A, default, WrongAlias>.

@JesusTheHun
Copy link

JesusTheHun commented Sep 20, 2024

@shicks Let's say your type is the following :

type Foo<A, B> = { a: A; b: B };

If you want to allow to skip parameter A while providing a default, you can do the following :

type DefaultA = number;
type Foo<A, B> = {
  [A] extends [never] ?
    { a: DefaultA; b: B } : 
  { a: A; b: B }
};

type FooSkipA = Foo<never, string>; // { a: number; b: string }

Also note that, since the consumers can see the type definition, they can provide the same type as the default. It's just less convenient for them.

So, while it's not the perfect solution, your problem is already solvable.

EDIT: also note that this is different than what this proposal is about. This proposal is about inference, not default values.

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

Successfully merging a pull request may close this issue.