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

Named Type Arguments & Partial Type Argument Inference #23696

Closed
wants to merge 14 commits into from

Conversation

weswigham
Copy link
Member

With this PR, we allow named type arguments, of the form Identifier = Type anywhere a type argument is expected. This looks like so:

const instance1 = new Foo<T = number, U = string>(0, "");
const result1 = foo<T = number, U = string>(0, "");
const tagged1 = tag<T = number, U = string>`tags ${12} ${""}`;
const jsx1 = <Component<T = number, U = string> x={12} y="" cb={props => void (props.x.toFixed() + props.y.toUpperCase())} />;
type A = Foo<T = number, U = string>;

These arguments do not need to come in any particular order, but must come after all positional type arguments. When you've used a named type argument, you may elide any other type arguments you wish. When you do so, the missing arguments will be inferred (and will not cause an error to be issued even if they do not have a default)

const instance4 = new Foo<U = string>(0, "");
const result4 = foo<U = string>(0, "");
const tagged4 = tag<U = string>`tags ${12} ${""}`;
const jsx4 = <Component<U = string> x={12} y="" cb={props => void (props.x.toFixed() + props.y.toUpperCase())} />;

(This is not valid for typespace references such as type references - those still have strict arity checks as there is no inference source)

Fixes #22631
Fixes #20122
Fixes #10571

🚲 🏠: Should named type argument assignments use a = (as they do in this PR now), or a : (as #22631 proposed)?

@kpdonn
Copy link
Contributor

kpdonn commented Apr 26, 2018

First question

(This is not valid for typespace references such as type references - those still have strict arity checks as there is no inference source)

I assume what you mean here is that something like the following isn't allowed:

type Example<T, U> = { t: T, u: U }
type NotAllowed = Example<U = string> // not allowed because nowhere to infer T from

Would that be allowed though if the missing type arguments already have defaults specified? Example:

type ExampleDefaults<T = any, U = any> = { t: T, u: U }
type IsThisAllowed = ExampleDefaults<U = string> // allowed?

Second question

When you do so, the missing arguments will be inferred (and will not cause an error to be issued even if they do not have a default)

Does that mean the skipped type arguments will still be inferred even if they have a default? Example:

declare function test<A = any, B = any>(arg: { a?: A, b?: B }): {a: A, b: B}

const r1 = test<string>({ b: "foo" })
const r2 = test<A = string>({ b: "foo" })

What are the types of r1 and r2 in those cases? Today the type for r1 is unfortunately { a: string, b: any} because explicitly passing one type argument causes type inference to be skipped for all of them.

@weswigham
Copy link
Member Author

Would that be allowed though if the missing type arguments already have defaults specified?

Yes, that should be fine. Though you've reminded me of a class of error I probably need to add a test and error for:

type Two<A, B = number> = [A, B]
type Bug = Two<B = string> // should error, A was not provided

Does that mean the skipped type arguments will still be inferred even if they have a default?

Yes, and if inference fails (ie, there are no inference sites) it still falls back to the default (which is always the case when inferring normal type parameters).

As for your example, in the first call your result is { a: string, b: any }, as no inference currently takes place unless there's a named type argument (though we may change that). In the second, the result should be { a: any, b: string}, as there's no inference drawn for A, so it falls back to its default.

@kpdonn
Copy link
Contributor

kpdonn commented Apr 26, 2018

In the second, the result should be { a: any, b: string}, as there's no inference drawn for A, so it falls back to its default.

I think you misread that case. It was

const r2 = test<A = string>({ b: "foo" })

so A is clearly string since it was explicitly passed via the named argument, and the question then was if B gets inferred as string from { b: "foo" } or whether its default is used instead of inference like in the r1 case. But based on what you said before that it sounds like the type would be { a: string, b: string} since A was a named argument and you said the unnamed ones are inferred before their defaults are used.

As for your example, in the first call your result is { a: string, b: any }, as no inference currently takes place unless there's a named type argument (though we may change that).

Definitely seems like it'd be good to change because it'd be confusing for these cases to be different

const r2 = test<A = string>({ b: "foo" }) // { a: string, b: string } 

// but if you skip the `A =` part because it's the first parameter it strangely becomes 

const r1 = test<string>({ b: "foo" }) // { a: string, b: any }

@kpdonn
Copy link
Contributor

kpdonn commented Apr 26, 2018

@weswigham
Some tests I think might be useful for potentially weird cases I didn't see covered yet:

declare function testNamingOtherParameters<A = any, B = any>(arg: { a?: A, b?: B }): { a: A, b: B }
const assumingNotAllowed = testNamingOtherParameters<B = A>({ a: "test" })
// I assume error here because A wouldn't be in scope on the right hand side

declare function stillDefaultsIfNoInference<X, A = string, B= number, C=boolean>(arg: { a?: A, b?: B, c?: C, x?: X}): { a: A, b: B, c: C, x: X }
const result1 = stillDefaultsIfNoInference<C = object> ({ b: "test" })
// expect result1 type is {a: string, b: string, c: object, x: {}}

declare function testConstraints<A extends string, B extends A>(arg?: { a?: A[], b?: B[] }): { a: A[], b: B[] }
const expectAllowed1 = testConstraints < B = "x" > ({ a: ["x", "y"] })
const expectAllowed2 = testConstraints < A = "x" | "y" > ({ b: ["x"] })
const expectError1 = testConstraints < B = "z" > ({ a: ["x", "y"] }) // error "z" not in "x" | "y"
const expectError2 = testConstraints < A = "x" | "y" > ({ b: ["x", "y", "z"] }) // error "z" not in "x" | "y"

declare function complexConstraints<A extends string, B extends A, C extends B>(arg: { a?: A[], b?: B[], c?: C[] }): { a: A[], b: B[], c: C[] }
const expectAllowed3 = complexConstraints < A = "x" | "y" | "z" > ({ a: ["x"], c: ["x", "y"] })

const expectError3 = complexConstraints<A = "x" | "y" | "z", C = "x" | "y">({b: ["x"]}) 
// error because B inferred to be "x" so C can't be "x" | "y"

const expectError4 = complexConstraints<A = "x">({c: ["y"]}) 


type ExampleDefaults<T = any, U = any, V extends string = string> = { t: T, u: U, v: V }
type ShouldBeAllowed<S extends string, V extends S = S> = ExampleDefaults<U = string, V = V>

// Should the following work? 
type InferredReturnType<F extends (...args: any[]) => R, R = any> = R
const expectAllowed4: InferredReturnType<F = () => string> = "test"
const expectError5: InferredReturnType<F = () => string> = 35 

Also my bikeshed opinion: I prefer : over = because = already appears commonly in type parameter declarations for defaults, while : currently doesn't often appear inside of generics. To me that means that : would make it easier to understand the structure of the code at a glance. For example:

type Type1<A = any, B = string, C = number, D = C, E = boolean> = [A, B, C, D, E]

type Type2<W = number, X = W, Y = object, Z = Y> = Type1<B = W, C = X, D = Y, E = Z>
// vs
type Type2<W = number, X = W, Y = object, Z = Y> = Type1<B: W, C: X, D: Y, E: Z>

Neither is bad, but the : version makes it even more obvious that one is a declaration and the other is an instantiation.

@weswigham
Copy link
Member Author

But based on what you said before that it sounds like the type would be

Oh, yeah, sorry - I read it wrong XD

@weswigham
Copy link
Member Author

// Should the following work? 
type InferredReturnType<F extends (...args: any[]) => R, R = any> = R
const expectAllowed4: InferredReturnType<F = () => string> = "test"
const expectError5: InferredReturnType<F = () => string> = 35 

Neither of those should error, because there's no inference being done (neither of those type references are actually invocations) and R is set to default to any.

@weswigham
Copy link
Member Author

weswigham commented Apr 26, 2018

@kpdonn Thanks for your test cases - I did change up my implementation a bit (OK, a lot) to handle some of them, however for a few your assumption of an error was incorrect!

For instance, in:

const expectError3 = complexConstraints<A = "x" | "y" | "z", C = "x" | "y">({b: ["x"]}) 

Inference does fail - we're not allowed to infer "x" for B when C is supposed to extend it. So then B is replaced with its constraint, A, which is set to "x" | "y" | "z", and then the call resolves just fine (as a "x"[] is assignable to a ("x" | "y" | "z")[])

const expectError1 = testConstraints < B = "z" > ({ a: ["x", "y"] }) // error "z" not in "x" | "y"

is similar - inference does fail because of the inference not validating against the constraint, but then we use the constraint as the inference result and the call resolve just fine, as an ("x" | "y")[] is assignable to a string[].

@kpdonn
Copy link
Contributor

kpdonn commented Apr 27, 2018

👍 Cool that makes sense. I thought of those cases specifically because I figured they'd be really hard to infer so I was mainly just curious to see what would happen with them.

sheetalkamat added a commit to microsoft/TypeScript-TmLanguage that referenced this pull request Apr 30, 2018
@insidewhy
Copy link

With this it's great that you can infer U in <T, U>(a: U) by using blah<T = string>(1). Seems a bit wordy though given blah<string>(4) is an error right now. Would much prefer the version that gives you an error right now to be equivalent to the "named type" version.

@insidewhy
Copy link

It just feels arbitrary that using a named type argument turns on inference of unspecified types when using a positional type argument does not.

Implementing this without also having #20122 is gonna introduce semantics that I'm pretty sure will confuse many people.

@alfaproject
Copy link

@ohjames according to the original post that issue is covered in this PR?

@treybrisbane
Copy link

@rubenlg I'm not sure I follow?

Using your example:

  • When the first foobar is called, a type variable T is instantiated as the provided type in its entirety, i.e. T = { foo: infer, bar: number }
  • When the second foobar is called, type variables T and U are instantiated as the types of the foo and bar fields within the provided type, respectively, i.e. T = infer and U = number

This is no different to the way ES6 value destructuring already works. Take the following for instance:

function barfoo(obj: { foo: string, bar: number }): [typeof obj['foo'], typeof obj['bar']] {
  return [obj.foo, obj.bar];
}
function barfoo({ foo: fooVal, bar: barVal }: { foo: string, bar: number }): [typeof fooVal, typeof barVal] {
  return [fooVal, barVal];
}

barfoo({ foo: 'abc', bar: 42 });

That's a value-level equivalent of your example. What problem does your example exhibit that this example does not?

@treybrisbane
Copy link

Unrelated to my previous comments...

It seems like it may be a better idea to split this work into two components:

  1. The ability to leverage inference when manually providing type arguments
    • Examples:
      • tuple<number, , string>(42, someObject, 'foo')
      • tuple<number, ?, string>(42, someObject, 'foo')
      • tuple<number, infer, string>(42, someObject, 'foo')
  2. The ability to provide type arguments by name, including leveraging inference
    • Examples:
      • tuple<T1 = number, T3 = string>(42, someObject, 'foo')
      • tuple<T1 = number, T2 = ?, T3 = string>(42, someObject, 'foo')
      • tuple<T1 = number, T2 = infer, T3 = string>(42, someObject, 'foo')

Doing this would help establish consistency around leveraging inference when providing type arguments, regardless of whether you're providing them in order or by some other means (i.e. by name).

Thoughts @weswigham? Or am I completely misunderstanding the kind of feature separation you're going for here? :P

@rubenlg
Copy link

rubenlg commented Jul 11, 2018

@Tbrisbane Good point. I see where you are coming from. Let me elaborate on my comment a bit more clarifying the differences that I see with destructuring.

In the case of ES6 value destructuring, the meaning at the call site doesn't change. You are always passing an object to the function. Whether the function uses destructuring for convenience or not, is an implementation detail that the caller of the function doesn't need to be aware of. The meaning at the call site is always the same, you call a function with an object.

Neither the Typescript compiler nor humans need to be aware of whether destructuring is used or not to process that function call, as the function contract is the same. TS models the types of the two functions slightly differently (because TS insists on keeping argument names on function types) but that doesn't matter, they are fully assignable to one another.

Your proposal doesn't have that property. The function call has different meanings depending on how the function is declared. The types of the two functions in my example above are very different, both for humans and for the compiler. One takes one generic, with any shape (even a number or string). The other one takes two named generics, but there is no structure to them (no object holding them together).

The call site in my example shows how both functions can be called with the same exact code, but these have very different interpretations:

  1. When calling the first function, it's defining a type with structure (it has to be an object containing two fields with specific names and types).
  2. When calling the second function it's providing specific types for two named generics, without any structure (no object type is defined containing fields).

That's what makes me uncomfortable, the fact that when reading the function call I (and the compiler) don't know if an object type is being declared, or just two generic types. Argument destructuring doesn't have that problem: from the caller perspective it's always an object.

@treybrisbane
Copy link

@rubenlg I see what you mean. Thanks for elaborating! :)

@AnyhowStep
Copy link
Contributor

I have a lot of generic type parameters in my classes for a project of mine, and I do something similar to what @Tbrisbane mentioned.

I'd have, maybe,

type Data = {
    field0 : type0,
    field1 : type1,
    /*snip*/
};
class ImmutableFoo<DataT extends DataT> {
    //creates new ImmutableFoo<> with data changed
    bar () : ImmutableFoo<{
        [key in keyof DataT] : (
            key extends "field5" ?
            SomeNewType :
            key extends "field22" ?
            SomeNewType2 :
            //Keep old type
            DataT[key]
        )
    }>;
}

I'm not sure if this will make writing these kinds of classes easier but I'm hopeful.

@kourge
Copy link

kourge commented Jul 25, 2018

@Airblader brings up a very good point. Given this revelation, I am motivated to update several libraries of mine to export more descriptive type variable names, lest a user has to deal with nondescript names like T, U, V.

To reiterate: prior to this, changing the type variable names would not require any call site updates, whereas after this, it can cause compilation errors if named type arguments are used.

@dead-claudia
Copy link

Just a thought: I've been doing this for a while to great success:

type SomeType<T extends {Foo: any, Bar: any, ...}> =
    // do things with T["Foo"], T["Bar"], etc.

// later
function foo(): SomeType<{
    Foo: Foo;
    Bar: Bar;
    // ...
}>;

I'm not convinced the named type parameter idea is really useful, especially since you can still pass an object parameter without actually creating the object itself. If anything, being able to "destructure" a type parameter would probably be the most useful.

@treybrisbane
Copy link

@isiahmeadows Yeah those are pretty much my thoughts as well

@weswigham
Copy link
Member Author

weswigham commented Aug 10, 2018

Superseded by #26349 and another feature that I'll have a proposal/prototype up for in the coming weeks.

@weswigham weswigham closed this Aug 10, 2018
matt-tingen added a commit to matt-tingen/DefinitelyTyped that referenced this pull request Aug 26, 2018
This will be important when
[named type arguments](microsoft/TypeScript#23696)
is added to Typescript. It is currently slated for 3.1.
@svieira svieira mentioned this pull request Sep 1, 2018
4 tasks
@simonbuchan
Copy link

@weswigham Looks like the roadmap should be updated.

@lewisl9029
Copy link

I tried the approach that @isiahmeadows posted earlier, and it works quite well, with one caveat: I can't seem to figure out how to apply different default generic arguments for each "named" argument.

@isiahmeadows @treybrisbane Any ideas on how default generic args could be implement in this pattern?

I actually tried to create a helper type that could be applied at the usage sites to supply a default value if a generic type is not supplied, but it doesn't seem to work as I'd expect it to.

@dead-claudia
Copy link

@lewisl9029 You can't really without defining a type alias injecting the defaults. Something like this might work if you want a general, cookie-cutter solution:

type Defaults<T, D> = T & Pick<D, Exclude<keyof D, keyof T>>

type Foo<T> = FooImpl<Defaults<T, { /* Your defaults */ }>>
type FooImpl<T> = ... // Your actual type implementation

I've never used it nor had a significant need - most of my needs were either all optional or default undefined, where T["key"] is sufficient.

@AnyhowStep
Copy link
Contributor

@lewisl9029 I generally work with the builder pattern when working with generics that have very complex structures.

You get a builder instance and it has sane defaults for most/all of the fields.
Then, you call methods that create a completely new instance of the builder that mutate the type.

At the end of it, you should have an instance of a type that has sane defaults and is highly configurable.

@jedwards1211
Copy link

@weswigham to be clear, if there is a type with 10 parameters, and we want to specify just the last parameter, is there still a plan for a way to do that without 9 commas or knowing the order of the type parameters?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.