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

Support C# / Rust-style "where" syntax for generic parameters #42388

Open
5 tasks done
danvk opened this issue Jan 17, 2021 · 13 comments
Open
5 tasks done

Support C# / Rust-style "where" syntax for generic parameters #42388

danvk opened this issue Jan 17, 2021 · 13 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@danvk
Copy link
Contributor

danvk commented Jan 17, 2021

Suggestion

🔍 Search Terms

  • rust where

✅ Viability Checklist

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code: this would introduce new syntax that would currently be an error.
  • This wouldn't change the runtime behavior of existing JavaScript code: this would be a purely type-level construct
  • This could be implemented without emitting different JS based on the types of the expressions: this would get erased at runtime
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.): this would be a purely type-level feature
  • This feature would agree with the rest of TypeScript's Design Goals. It's more inspired by the syntax in Rust than attempting to replicate it

⭐ Suggestion

Instead of writing:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

You'd also be allowed to write:

function getProperty<T, K>(obj: T, key: K)
  where
  T extends object,
  K extends keyof T
{
  return obj[key];
}

It might even be preferable to leave T and K off the generic parameters list (the bit inside <..>), since the intent is most likely for them to be inferred based on obj and key, but we'll get to this later.

For a type alias, instead of:

type Pick<T extends object, K extends keyof Type> = {[k in K]: T[k]};

you'd also be able to write:

type Pick<Type, Keys> = {[K in Keys]: Type[K]}
  where
  Type extends object,
  Keys extends keyof Type;

This mirrors an identical syntax in Rust (see also Rust RFC 135) (update: and also C#, so I guess Anders knows about this!). It would solve three distinct problems:

  1. Legibility
    When a function or type alias has many generic arguments, each with an extends clause and a default value, it can get difficult to pick out what the type parameters are, or even how many of them there are. A where clause lets you push the generic bounds and defaults outside the parameter list, improving its legibility.

  2. Scoped type aliases
    There's no easy type-level equivalent of factoring out a variable to eliminate duplicated expressions like you would in JavaScript. A where clause would make it possible to introduce type aliases that don't appear in the generic parameter list.

  3. Partial inference for functions
    See Allow skipping some generics when calling a function with multiple generics #10571. It's not currently possible to specify one type parameter for a generic function explicitly but allow a second one to be inferred. By creating a place to put types that's not the parameter list, a where clause would make this possible.

There are examples of all three of these in the next section.

💻 Use Cases

Legibility

There are many examples of complicated generic parameter lists out there. Here's one chosen at random from react-router:

export interface RouteChildrenProps<Params extends { [K in keyof Params]?: string } = {}, S = H.LocationState> {
    history: H.History;
    location: H.Location<S>;
    match: match<Params> | null;
}

It's clearer that there are two type parameters if you move the constraints and default values out of the way:

export interface RouteChildrenProps<Params, S> where
  Params extends { [K in keyof Params]?: string } = {},
  S = H.LocationState
{
    history: H.History;
    location: H.Location<S>;
    match: match<Params> | null;
}

The existing workaround for this is to put each type parameter on its own line:

export interface RouteChildrenProps<
  Params extends { [K in keyof Params]?: string } = {},
  S = H.LocationState
> {
    history: H.History;
    location: H.Location<S>;
    match: match<Params> | null;
}

It's a judgment call which you prefer. I find the other two uses more compelling!

Scoped type aliases

With complicated generic types and functions, it's common to have repeated type expressions. Here's a particularly egregious example (source):

import * as express from 'express';
/** Like T[K], but doesn't require K be assignable to keyof T */
export type SafeKey<T, K extends string> = T[K & keyof T];
export type ExtractRouteParams<Path extends string> = ...;  // see https://twitter.com/danvdk/status/1301707026507198464

export class TypedRouter<API> {
  // ...
  get<
    Path extends keyof API & string,
  >(
    route: Path,
    handler: (
      params: ExtractRouteParams<Path>,
      request: express.Request<ExtractRouteParams<Path>, SafeKey<SafeKey<API[Path], 'get'>, 'response'>>,
      response: express.Response<SafeKey<SafeKey<API[Path], 'get'>, 'response'>>,
    ) => Promise<SafeKey<SafeKey<API[Path], 'get'>, 'response'>>
  ) {
    // ... implementation ...
  }
}

wow that's a lot of repetition! Here's what it might look like with where:

export class TypedRouter<API> {
  // ...
  get<Path extends keyof API & string>(
    route: Path,
    handler: (
      params: Params,
      request: express.Request<Params, Response>,
      response: express.Response<Response>,
    ) => Promise<Response>
  )
  where
    Params = ExtractRouteParams<Path>,
    Spec = SafeKey<API[Path], 'get'>,
    Response = SafeKey<Spec, 'response'>
  {
    // ... implementation ...
  }
}

By introducing some local type aliases in the where list, we're able to dramatically reduce repetition and improve clarity. We should actually remove Path from the type parameters list since the intention is for it to be inferred, but let's save that for the next example.

Existing workarounds include factoring out more helper types to reduce duplication, or introducing an extra generic parameter with a default value, e.g.:

class TypedRouter<API> {
  // ...
  get<
    Path extends keyof API & string,
    Spec extends SafeKey<API[Path], 'get'> = SafeKey<API[Path], 'get'>,
  >(
    route: Path,
    handler: (
      params: ExtractRouteParams<Path>,
      request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, 'response'>>,
      response: express.Response<SafeKey<Spec, 'response'>>,
    ) => Promise<SafeKey<Spec, 'response'>>
  ) {
    // ... implementation ...
  }
}

This still repeats the type expression twice (SafeKey<API[Path], 'get'>), but since it's used three times, it's a win! This is kinda gross and creates confusion for users about whether Spec is a meaningful generic parameter that they'd ever want to set (it isn't).

Partial inference for functions

Sometimes you want to infer one generic parameter to a function and have the others be derived from that (#10571). For example (following this post):

declare function getUrl<
  API, Path extends keyof API
>(
  path: Path, params: ExtractRouteParams<Path>
): string;

This fails if you pass API explicitly and try to let Path be inferred:

getUrl<API>('/users/:userId', {userId: 'bob'});
//     ~~~ Expected 2 type arguments, but got 1. (2558)

This problem could be solved by using where to introduce a type parameter that's not part of the generics list:

declare function getUrl<API>(path: Path, params: ExtractRouteParams<Path>): string
  where Path extends keyof API;

This would allow Path to be inferred from the path parameter while still specifying API explicitly and enforcing the extends keyof API constraint. The only workarounds I'm aware of now involve introducing a class or currying the function to create a new binding site:

declare function getUrl<API>():
  <Path extends keyof API>(
    path: Path,
    params: ExtractRouteParams<Path>
  ) => string;

A where clause would help with the general problem that there are two reasons to put a type parameter in the generic parameters list for a function:

  1. You want users to specify it
  2. You want it to be inferred

and there's no syntax for distinguishing those. A where clause would let you do that.

@ghost
Copy link

ghost commented Jan 18, 2021

Scoped type aliases are enough to get my vote, but does it have to follow Rust's syntax? Might there be a better syntax to do this?

@danvk
Copy link
Contributor Author

danvk commented Jan 18, 2021

@00ff0000red it doesn't have to follow Rust's syntax exactly, I mostly offer that as evidence that it's not a completely crazy idea (and I assume much of the discussion in the Rust RFC would also be relevant to TypeScript).

@ShuiRuTian
Copy link
Contributor

Seems good. But I guess there must once be a good reason for not using "where" as generic constraint.

Because C# does use similar syntax https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters

They are designed by the same person, so... I am curious too :)

@MartinJohns
Copy link
Contributor

Scoped type aliases are enough to get my vote

Related: #40780 / #30979.

@danvk danvk changed the title Support Rust-style "where" syntax for generic parameters Support C# / Rust-style "where" syntax for generic parameters Jan 19, 2021
@danvk
Copy link
Contributor Author

danvk commented Jan 19, 2021

Seems good. But I guess there must once be a good reason for not using "where" as generic constraint.

Because C# does use similar syntax https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/constraints-on-type-parameters

They are designed by the same person, so... I am curious too :)

Good point @ShuiRuTian, now I feel sheepish about calling this Rust-style syntax :) Updated the title of the issue and added a note.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Jan 19, 2021
@danvk
Copy link
Contributor Author

danvk commented Jan 20, 2021

Looking at the Rust RFC, another motivator there was being able to specify constraints that involved multiple type parameters. So something like:

const users = [
  ['John', 'Doe'],
  ['Bobby', 'Droppers'],
] as const;

function greet<First, Last>(first: First, last: Last)
  where [First, Last] extends typeof users[number] {
  // ...
}

I wouldn't say this is common, but I can think of a few situations I've run into where it would have been convenient.

@Jack-Works
Copy link
Contributor

So it's actually not a syntax sugar, it's introduced a new way to restrain the generic types right?

@danvk
Copy link
Contributor Author

danvk commented Jan 20, 2021

So it's actually not a syntax sugar, it's introduced a new way to restrain the generic types right?

If the constraint involves multiple parameters, yes. That wouldn't be in v1 of this proposal, I just wanted to flag it as a possibility that this syntax opens up.

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Jan 20, 2021

I don't love the syntactic positioning of this or the idea that it would be a place to introduce type parameters, but I'm compelled by this as an alternative approach to some other feature requests (specifically #14520 upper-bounded generics). You could imagine writing things like this

interface Array<T> {
  // Proposing that "where" go at the end of the type parameter list
  includes<U where T extends U>(el: U): boolean
}

@RyanCavanaugh
Copy link
Member

Also, if the where clauses didn't influence inference at all, which I think would be desirable, then you could solve non-inferential type parameter (#14829) use cases:

declare function assertEqual<T, U where U extends T, T extends U>(actual: T, expected: U): boolean;
const g = { x: 3, y: 2 };
// Would correctly error
assertEqual(g, { x: 3 });

@danvk
Copy link
Contributor Author

danvk commented Jan 20, 2021

Also, if the where clauses didn't influence inference at all, which I think would be desirable, then you could solve non-inferential type parameter (#14829) use cases:

declare function assertEqual<T, U where U extends T, T extends U>(actual: T, expected: U): boolean;
const g = { x: 3, y: 2 };
// Would correctly error
assertEqual(g, { x: 3 });

Or perhaps

declare function assertEqual<T, U where [U, T] extends [T, U]>(actual: T, expected: U): boolean;

@danvk
Copy link
Contributor Author

danvk commented Mar 28, 2021

Linking a few other related issues for local type aliases that I missed earlier #23188 (2018), #41470 (2020)

@shicks
Copy link
Contributor

shicks commented Jun 14, 2023

The topic of scoped types aliases came up again recently as a tangent in #26349 (on partial inference). This issue seems maybe the most lively/promising place to move the discussion to where it's still on-topic. To briefly summarize the tangent, it started when I mentioned a gist where I explore the synergy between partial inference and what I've called "private types" (e.g. private T = infer, where the T really is a type parameter, but one that the API author doesn't want a caller to ever write explicitly, hence private). This led to some agreement that the concept is particularly useful (though I'm not sure the proposal in this issue actually handles this particular synergy well).

Stepping back, let me try to summarize the bigger picture. There are three main contexts where type aliases are useful:

  1. in type nodes (which often themselves show up in top-level type aliases), e.g.
    type Foo<T> =
        // ...somehow define alias U...
        Bar<U>;
    but might also be relevant as anonymous inline types, e.g.
    type Mapped<T> = {
      [K in keyof T]: /* ...somehow define alias U... */ Bar<U>
    }
  2. in classes/interfaces, e.g.
    class Foo<T> {
      // ...somehow define alias U...
      bar(): U {...}
    }
  3. in functions, e.g.
    function foo<T /* ...somehow define alias U... */>(arg: T): U {...}

These contexts are unfortunately somewhat opposed to each other: e.g. #23188 and #41470 both propose "type node" syntaxes, which support inline use in mapped types, but wouldn't (directly) benefit the classes/functions case at all (whereas e.g. #7061 wouldn't help the inline case). Syntax proposals that "live inside the <...>" are more general, though they leave out the inline/anonymous usage, and they risk conflating aliases with parameters (see concerns by @RyanCavanaugh about abusing type parameters on #32525 and #45311).

In addition to simply local aliases, I also want to call attention to several distinct-but-related user needs, which have varying degrees of relevance across the three contexts:

  1. Inference. Local aliases may want to participate in partial inference (this is the case I mentioned above, from Implement partial type argument inference using the _ sigil #26349), where the alias probably wants to be treated as a real type parameter as far as all the bookkeeping goes; this would obviously depend on a definition-site opt-in rather than call-site, which is what Implement partial type argument inference using the _ sigil #26349 tracks.
  2. Constraints. There's a handful of bugs around constraint checking (Support C# / Rust-style "where" syntax for generic parameters #42388, Allow conditional check on generic parameters #51385). The current <T extends ...> syntax is very limited: it can only set an upper bound (rather than a lower bound, see Enable type parameter lower-bound syntax #14520), and because this bound participates in inference, it disallows cycles (which further limits its usefulness quite a bit).
  3. Associated types. There are some use cases for making these type aliases available in outside scopes as well, e.g. Suggestion: Allow local types to be declared in interfaces #9889. This obviously adds some additional complications, and I see it as a bonus rather than a requirement.

The proposed syntax options include the following:

  • semicolon inside the type parameter list, to separate the public from the private parameters (note that this has some analogue with Private function parameters #52811 private (runtime) function parameters)
  • where, either inside or outside the parameter list
  • satisfies to introduce a non-bounding constraint (i.e. T satisfies IsStringLiteral<T>, which would be a cycle otherwise)
  • private or # for private type parameters
  • _ or infer to opt-into partial inference, either at the call-site or the definition-site
  • as for introducing a "yoda"-style alias (SomeComplicatedExpression<T> as U)
  • let..in or {{ /*...aliases…*/ type=... }} for an anonymous approach

The original tangent was about "private types". As referenced above, putting these into the bracketed type parameter list is potentially a mistake, but it's currently the only hammer we have available, so I think a lot of developers are biased in favor of it. Even without direct language support for private type parameters, it's possible to at least partially emulate it with an impossible-to-write optional type parameter, though this only works when inference is off (i.e. the non-optional parameters are explicitly specified).

I'd therefore consider definition-site partial inference and arbitrary constraints to be the two most important requirements (with aliasing being a nice bonus, but not strictly required). The former should hopefully follow from #26349, so the main question to me is how to express arbitrary constraints. I like satisfies as an analog to extends inside the parameter list - the RHS of this clause could reference all parameters in the list (no order dependence) and would be checked after all inference, leading to an error (or a failed overload match, for overloaded functions) if the constraint fails. This could be abused if necessary to resolve arbitrary conditions

function foo<T satisfies SomeCondition<U> extends true ? T : never, U extends T>(...)

and it's likely to be relatively straightforward to implement in place.

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

6 participants