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/keyed type parameters #54254

Open
5 tasks done
Andarist opened this issue May 15, 2023 · 21 comments
Open
5 tasks done

Named/keyed type parameters #54254

Andarist opened this issue May 15, 2023 · 21 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@Andarist
Copy link
Contributor

Andarist commented May 15, 2023

Suggestion

πŸ” Search Terms

named type parameters generic bag

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Disclaimer: I somewhat suspect that a similar feature request exists already but I couldn't find any.

I'd like to suggest an ability to specify named type parameters. Some libraries have long lists of generic type parameters (XState currently sits at 5: here) and it becomes hard for consumers to remember the order of those.

I think that it might be hard for me to beat @weswigham's arguments from here:

This could be particularly useful in Vue, as then they could introduce a bag for the type parameters passed into their many generic functions. This would enable them to add more type arguments as needed for improved inference or for future features, without breaking existing consumers who are manually specifying a subset of parameters (simply make new arguments in the bag optional) and also allowing that bag to be extended (via interface reopening) by any vue plugins, such as vuex (which then enables them to add overloads to vue-core methods which can carry through the new type information they provide).

I think that it's best to model this feature after objects and destructuring patterns. That should create a familiar syntax for end users.

I'm not really married to any particular syntax and I think it's somewhere up for debate, one variation that comes to mind is this:

declare function fn<{ T extends number; T2 extends string; }>(a: T, b: T2): void

fn<{ T: 100, T2: 'foo' }>(100, 'foo')

πŸ“ƒ Motivating Example

Any library with more than 2-3 type parameters. Vue, TanStack Query, XState and more come to mind.

Currently TanStack query has such an overload for its useQuery:

export function useQuery<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey,
>(
  options: Omit<
    UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    'initialData'
  > & { initialData: TQueryFnData | (() => TQueryFnData) },
): DefinedUseQueryResult<TData, TError>

This could likely be rewritten to something like:

export function useQuery<{
  fnData = unknown,
  error = unknown,
  data = fnData,
  key extends QueryKey = QueryKey,
}>(
  options: Omit<
    UseQueryOptions<fnData, error, data, key>,
    'initialData'
  > & { initialData: fnData | (() => fnData) },
): DefinedUseQueryResult<data, error>

A nice trait of this feature would be that generic names would become autocompletable. Today we might rely on signature help when typing but it doesn't provide the ideal experience. It has too much information (even if the interesting piece of information is in bold), it's squished into a single line and it's hard to focus on what we are typing (plus, of course, we can't selectively start typing those type parameters "out of order").

πŸ’» Use Cases

Currently one might use a "generic bag" to imitate this feature. Using the previous example this could look like:

export function useQuery<T extends { fnData: unknown; error: unknown; data: unknown; key: QueryKey }>(
  options: Omit<
    UseQueryOptions<T['fnData'], T['error'], T['data'], T['key']>,
    'initialData'
  > & { initialData: T['fnData'] | (() => T['fnData']) },
): DefinedUseQueryResult<T['data'], T['error']>

One of the problems with this is that it's not easy to add defaults to specific slots, one has to resort to helper types like WithDefaults and implement that logic on their own.

In fact, I currently have an open PR that would allow to infer T using such indexes like in the example above (this PR builds on top of an already referenced @weswigham's PR).

My primary motivation is to enable such inference (based on indexes) for reverse mapped types. It's important for those to create multiple relationships within a single object property value/tuple element. It could be a nice addition for other type parameters that would make this feature more consistent. However, I think that when it comes to regular type parameters this approach has an important disadvantage when compared to this proposal.

Inferring using indexes doesn't provide the same capabilities as inferring to naked type parameters:

  1. we can't easily default them
  2. we can't rely on different sets of type parameter modifiers (in/out/const) for each "slot" (it's not possible to annotate part of the type parameter)
  3. it's hard to reason about variance of those "slots", I'm not even quite sure how such indexes behave when it comes to variance
  4. the upcoming partial inference sigil won't be applicable for such a "slot"

I think that this featue is important to address those concerns and to enhance the flexibility for library authors.

When it comes to the implementation... I think that this could be added fairly easily. The main things that would have to be added to add support for this would be the changes in the parser and code "matching" the names with the parameters list. The parameters lists should still be kept internally as a flat array and matching would be implemented only on boundaries.

@fatcerberus
Copy link

I somewhat suspect that a similar feature request exists already but I couldn't find any.

I thought the same thing, and a quick search indeed turned up at least one: #38913

@ssalbdivad
Copy link

Not only would this functionality be incredibly useful, the suggested syntax looks great!

It implies the ordering is arbitrary, is clearly distinguishable from an object literal, and is about as concise as you could imagine.

If one of the preferinfer-style optionality syntaxes ends up getting implemented, it could also compliment this very well! (see @Andarist's original PR here and some suggestions here)

@fatcerberus
Copy link

fatcerberus commented May 15, 2023

fn<{ T: 100, T2: 'foo' }>(100, 'foo')

This feels ambiguous at the call site whether it’s calling fn with a single type argument set to an object type vs. two named type arguments. Disambiguation might be possible (depending on defined overloads), but I suspect the TS team won’t like it.

@ssalbdivad
Copy link

@fatcerberus Good point, I didn't consider the ambiguity on invocation (I very rarely actually pass generic parameters to a function manually). Perhaps dropping the object literal syntax on invocation could work?

fn<T: 100, T2: 'foo' >(100, 'foo')

@Andarist
Copy link
Contributor Author

Andarist commented May 15, 2023

This feels ambiguous at the call site whether it’s calling fn with a single type argument set to an object type vs. two named type arguments. Disambiguation might be possible (depending on defined overloads), but I suspect the TS team won’t like it.

Ah, yes - this is a good point. I meant to mention this and I forgot. I think that it's fairly OK here since it feels quite natural and one could draw a parallel to pure JS:

function test1(obj) { obj.a; obj.b }
function test2({ a, b }) { a; b }

I understand that the latter kinda imitates named parameters through the object destructuring... but it still feels pretty close to me. I don't feel like this distinction is important to the users. JS supports a lot of things and new things might come to the language, like even some kind of extractors (@rbuckton's proposal: https://github.com/tc39/proposal-extractors ). With such things... we can never truly be sure how a thing gets interpreted based on the call site alone.

However, I really don't mind any syntax. I'm pretty sure that we could figure out something that would satisfy the TS team. The main goal of this issue is to get some kind of interest in this feature and to start the conversation. As always... I would gladly work on the actual implementation for this :)

@phryneas
Copy link

I see your 5 generic type parameters and raise to 10, 8 of which are in a hacky version of such a bag that is an absolute pain to maintain and use in createAsyncThunk:

https://github.com/reduxjs/redux-toolkit/blob/e9dfc9384fb2f92eba75335b8b6f293c3c469269/packages/toolkit/src/createAsyncThunk.ts#L402-L406

This feature would be phenomental.

@jcalz
Copy link
Contributor

jcalz commented May 15, 2023

If #20126 or something like it were to be merged, then you could get this without any new syntax more or less. You could constrain a single type parameter to an object type whose keys are your desired β€œnames” and whose value types represent their constraints, and then use indexed access types to refer to them. Maybe not as ergonomic but it would work fine. But without inference it’s not feasible.

@fatcerberus
Copy link

fatcerberus commented May 15, 2023

@Andarist To be fair, the destructured-parameter is doing the same thing at the call site, namely passing an object value. Here TS would have to interpret the call site differently (single atomic object type vs. separate named types) depending on the signature and that might be a dealbreaker. Basically you have to know the type of fn before you can even parse the callβ€”β€œtype-directed parsing” if you will, which I’m pretty sure there’s currently zero precedent for in TS.

This does give me an idea though: Maybe we could take a leaf from the JS parameter destructuring book and just make this a new sort of multivariate constraint (i.e. you constrain the properties individually); at the call site it would actually be a single object type and the implementation could just refer to the generics as T["foo"].

EDIT: Heh, @jcalz beat me to it with almost the exact same idea.

@Andarist
Copy link
Contributor Author

EDIT: Heh, @jcalz beat me to it with almost the exact same idea.

I kinda beat both of you here :P

I already mentioned #20126 in the "use cases" section. This is a nice approximation of the requested feature but this feature aims to solve some of the problems of that solution. I listed type parameter defaults, partial inference, potential variance problems, and type parameter modifiers as things that are not covered by it.

@jcalz
Copy link
Contributor

jcalz commented May 15, 2023

Oh so now I'm supposed to read things before I reply to them?

@bowheart
Copy link

Love this, just want to clarify some of the usages we'd want this to cover.

1) Accessing Other Keys

Since the following is possible with ordered generics:

type MyType = { myField: number; otherField: string }

// works:
declare function fnA<A extends MyType, B extends Omit<A, 'myField'>>(a: A, b: B): void

// also works:
declare function fnB<A extends Omit<B, 'myField'>, B extends MyType>(a: A, b: B): void

I think I'd expect there to be no issue accessing other keys:

declare function fnC<{ A extends MyType; B extends Omit<A, 'myField'> }>(a: A, b: B): void

However, to do this with normal structured types, you need access to the type itself:

interface MyInterface {
  a: MyType;
  
  // works:
  b: Omit<MyInterface['a'], 'myField'>;
  
  // doesn't work because `a` itself isn't declared:
  // b: Omit<a, 'myField'>;
}

I def don't know, but I feel like this could complicate things or make this require a different syntax.

2) Partial Inference

Just to clarify partial inference as @Andarist mentioned, with this inference working:

declare function fnA<{ A extends string, B extends A[] = [A] }>(a: A): B

fnA('a') // ['a']

Then, if #54047 lands, I'd expect this to just work:

declare function fnA<{ A extends number; B extends A[] = [A] }>(a: A): B

fnA<{ A: 'a' }>('a') // ['a']

3) Passing Generic Bags

With large generic bags, it's convenient to be able to pass the whole bag around. The proposed syntax doesn't look like it would really cover this.

declare function fnA<{ A extends MyType; B extends Omit<A, 'myField'> }>(
  a: A,
  b: B
): MyReturnType<{ A, B }> // <- do this without specifying every generic?

Here's one real-world example where this comes in very handy with OG generic bags. I'd love for this feature to cover this.

4) Declaring Generic Bags

With current generic bag workarounds, it's possible to declare the bag itself using a standalone type:

type MyGenericsBag = {
  A: MyType;
  B: Omit<MyGenericsBag['A'], 'myField'>;
}

Then this generics bag can be used as a generic on any function/class/etc. When lots of generics are involved, it would be tedious to have to declare the same generics bag in many places, so I'd rank this pretty highly on my wishlist.

However, I feel like this one would be very difficult to support. I can't really think of a syntax without introducing a new keyword like:

declare function fnA<G typemap MyGenericsBag>(a: G.A, b: G.B): void

But then I feel this loses some of the elegance of the proposed syntax. Though this would also solve #3.

5) Accepting Partials

I'd expect a function to be able to specify partial generics and have the types of all relevant values fully inferred:

class MyClass<{ A extends number; B extends number }> {
  constructor(public a: A, public b: B) {}
}

const instanceA = new MyClass<{ A: 1, B: 2 }>(1, 2)
const instanceB = new MyClass<{ A: 3, B: 4 }>(3, 4)

function passMeInstances<T extends MyClass<{ A: 1 }>>(instance: T) {
  instance.a // TS should know this is `1`
  instance.b // wasn't specified, so defaults to `number`
}

passMeInstances(instanceA) // good
passMeInstances(instanceB) // error - A: 3 not assignable to A: 1

This is one aspect where current generics bag workarounds fall a little short. While the above example would work, TS fails to infer more complex types like tuples with specific shapes. To make it work, you have to add a cast inside the function body.

You can see that on this Zedux doc page (see the comment that says "Current TS shortcoming"). I'd love for this feature to make such inference much more robust.

Summary

I'm a big fan of this idea. I really hope it becomes a thing. Maybe I'm asking too much, but I'm sure I'm not alone in wanting this feature to be powerful enough that it completely replaces the current generics bag workarounds we have to do - which are pretty powerful already, so it would take a powerful feature to replace them.

@eloytoro
Copy link

eloytoro commented May 16, 2023

I love this idea, something I've wanted since default generic types existed

About the syntax, I don't think it should move forward with the proposed one as it's not just vague, it can lead to several pitfalls:

Modifying the function signature would lead to silent "reinterpretation" of usages of the default values. As an example, imagine that the signature of the function fn changes

// Before
declare function fn<{ T extends number; T2 extends string; }>(a: T, b: T2): void

// After
declare function fn<T, T2 = string>(a: T, b: T2): void

Then this existing usage of the function would change meaning

fn<{ T: 100, T2: 'foo' }>(100, 'foo')

Before it meant that T was 100 and T2 was 'foo', but after it means that T is set to { T: 100, T2: 'foo' } and T2 to string

My proposal would be to follow an python-esque syntax for named arguments

fn<T = 100, T2 = 'foo'>(100, 'foo')

If we compare that to the example before, it would fix the ambiguity when changing the function's signature

@Andarist
Copy link
Contributor Author

  1. Accessing Other Keys

I feel like this one is covered already by the proposal. Those bindings at the declaration site would work the same way as the current type parameters.

  1. Partial Inference

Your example is not about what I call a partial inference (I think?). This particular example would just pick up a default for B based on the provided A.

  1. Passing Generic Bags

Right. I was thinking about this as well but I'm not sure what would be the right syntax for this. We'd need a way to both support the name for the bag itself and for its elements. Something like this could work:

declare function fnA<TBag: { A extends MyType; B extends Omit<A, 'myField'> }>(
  a: A,
  b: B
): MyReturnType<TBag> 

but I'm not completely sure how that feels yet. It feels close to tuple labels - that is both good (familiarity) and bad (different semantics, labels are purely decorative~) at the same time.

  1. Declaring Generic Bags

From the call site's PoV, this would be capable of accepting an object type and I think that it's fine to accept just any structured type. As long as we can match the required "properties" then it should be fine.

  1. Accepting Partials

That's likely depending on what is being discussed in relation to the upcoming _ partial inference sigil and stuff. I wouldn't make this a special feature of this proposal here, it should just follow the outcome of that other feature.

@eloytoro
Copy link

Also, the current proposal only allows types for which named generics are possible to be defined in a specific way, i bet most developers would struggle to decide which syntax to use because as I understand the new syntax would restrict generics to only be passed as arguments when named, and positional generic arguments would be illegal and vice-versa

@Andarist
Copy link
Contributor Author

My proposal would be to follow an python-esque syntax for named arguments

That's syntactically less appealing to me but I also don't see this as a major problem. I could live with this.

One caveat of this proposal is that it doesn't have a clean way of addressing some of the things mentioned by @bowheart (3 and 4)

@bowheart
Copy link

Cool, yeah I'm just clarifying most of these - making sure they aren't forgotten and if any are too much for the initial feature that we can make follow-up items.

Your example is not about what I call a partial inference

Sorry, it was a bad example. I'm referring to #54047. I'm unsure how the partial inference sigil would work since it currently relies on order.

I was trying to clarify whether we're expecting it to infer all other params automatically or if the call site would need to use a new syntax to specify which generics to infer. I assumed auto-infer since I'm always wary about introducing new syntax. But now that I think about it, I'm pivoting from that assumption.

Here's a better example:

function fnA<{ A = 1; B = 2; C = 3 }>(a: A, b: B, c: C): [A, B, C] {
  return [a, b, c]
}

const nums = [10, 20, 30] as const

// are B and C inferred?
fnA<{ A: 10 }>(...nums) // [10, 20, 30]

// or defaulted
fnA<{ A: 10 }>(...nums) // [10, 2, 3] (would actually error since 20 is not assignable to 2)

// do we need a new syntax to specify which other generics to infer:
fnA<{ A: 10, B }>(...nums) // [10, 20, 3] - B inferred, C defaulted

I like the python syntax (dare I mention PHP?). It may be wise to go that route since other languages have had success with it. But one thing I like about the "destructuring"-esque syntax is it feels like JavaScript. Passing generics and passing parameters have always felt similar, which helps with the learning curve. I'd love to keep that similarity.

3 is definitely low-priority. I'd much rather have a solid syntax if I had to choose. Without 4 though, the current generics bag workarounds would still be preferable in some situations. Maybe that's fine, but it's a tradeoff worth considering.

@jedwards1211
Copy link

jedwards1211 commented May 28, 2023

A similar suggestion from long ago got abandoned because someone pointed out that object shape types get us halfway to named types: #23696 (comment)

But as that issue pointed out, some kind of destructuring/defaulting would be nice, which is exactly why this proposal is so important.

TS badly needs this; there's a reason so many languages end up implementing named function arguments, and why JS ended up implementing destructuring with defaults, and type parameters are no different. Dealing with more than two or three positional parameters is inevitably tedious; I've seen types with like 9 parameters when digging through code generated by Prisma, and it would be a lot easier to understand those types if there were named type parameters.

@bowheart the difference in syntax between accessing Python named arguments and kwargs is clunky, it's much more elegant in JS that named arguments and custom additional arguments work through the same core features of object literals and destructuring. It should be the same way with object types as parameters. Any other syntax would be unreasonably different from the rest of JS.

@jedwards1211
Copy link

jedwards1211 commented May 28, 2023

@Andarist TS already has tuple type spread and rest spread, so if you added tuple type defaults, object type spread and object type rest spread to the features in OP, it would bring object/array type declarations to parity with JS object/array value declarations, which would be supremely elegant.

@c7hm4r
Copy link

c7hm4r commented Aug 4, 2023

I agree with @eloytoro . It would be nice to have a new syntax which is compatible with existing TypeScript code. I doubt that the majority of library maintainers will rewrite their definitions just to allow for named parameters if the rewrite would be a breaking change.

Example found in fastify:

export interface FastifyReply<
  RawServer extends RawServerBase = RawServerDefault,
  RawRequest extends RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
  RawReply extends RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
  RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
  ContextConfig = ContextConfigDefault,
  SchemaCompiler extends FastifySchema = FastifySchema,
  TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault,
  ReplyType extends FastifyReplyType = ResolveFastifyReplyType<TypeProvider, SchemaCompiler, RouteGeneric>
> { /*...*/ }

It would be nice if one could use the type like:

const handler = (request: any, reply: FastifyReply<ReplyType = MyEntity>) => {};

Instead, the currently best approach seems to be to write a fragile wrapper duplicating fastify's default types:

type ReplyTypeFirst_FastifyReply<
  ReplyType extends FastifyReplyType,
  RawServer extends RawServerBase = RawServerDefault,
  RawRequest extends RawRequestDefaultExpression<RawServer> = RawRequestDefaultExpression<RawServer>,
  RawReply extends RawReplyDefaultExpression<RawServer> = RawReplyDefaultExpression<RawServer>,
  RouteGeneric extends RouteGenericInterface = RouteGenericInterface,
  ContextConfig = ContextConfigDefault,
  SchemaCompiler extends FastifySchema = FastifySchema,
  TypeProvider extends FastifyTypeProvider = FastifyTypeProviderDefault,
> = FastifyReply<
  RawServer,
  RawRequest,
  RawReply,
  RouteGeneric,
  ContextConfig,
  SchemaCompiler,
  TypeProvider,
  ReplyType
>;

const handler = (request: any, reply: ReplyTypeFirst_FastifyReply<MyEntity>) => {};

(Disclaimer: The RouteGeneric type parameter also needs to be adapted to get the reply types validated with fastify's fluent API.)

@so1ve
Copy link

so1ve commented Sep 1, 2023

This feature is pretty useful! However, I would like to oppose the current proposed syntax, since it is ambiguous. Using <T = 1, U = 2> is not a good idea IMO - This syntax is more like named parameters in python, but js itself doesn't have such syntax, which causes inconsistency.

Not a blocker! Just some suggestions. Since typescript itself has already introduced many non-js syntaxes to its Turing-complete type system, it won't be an issue if ts has introduced such python-like syntax to it.

Any thoughts?

@kronodeus
Copy link

Our library relies heavily on rich generic type parameters and this feature would be tremendously helpful to us. Anxiously watching this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests