-
Notifications
You must be signed in to change notification settings - Fork 12.5k
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
Conversation
First question
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
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 |
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
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 |
I think you misread that case. It was const r2 = test<A = string>({ b: "foo" }) so
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 } |
@weswigham 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 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 |
Oh, yeah, sorry - I read it wrong XD |
// 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 |
@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 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 |
👍 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. |
With this it's great that you can infer |
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. |
@ohjames according to the original post that issue is covered in this PR? |
@rubenlg I'm not sure I follow? Using your example:
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? |
Unrelated to my previous comments... It seems like it may be a better idea to split this work into two components:
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 |
@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:
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. |
@rubenlg I see what you mean. Thanks for elaborating! :) |
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. |
@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 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. |
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. |
@isiahmeadows Yeah those are pretty much my thoughts as well |
Superseded by #26349 and another feature that I'll have a proposal/prototype up for in the coming weeks. |
This will be important when [named type arguments](microsoft/TypeScript#23696) is added to Typescript. It is currently slated for 3.1.
@weswigham Looks like the roadmap should be updated. |
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. |
@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 |
@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. At the end of it, you should have an instance of a type that has sane defaults and is highly configurable. |
@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? |
With this PR, we allow named type arguments, of the form
Identifier = Type
anywhere a type argument is expected. This looks like so: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)
(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)?