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 locally scoped type alias nodes #23188

Open
weswigham opened this issue Apr 5, 2018 · 11 comments
Open

Support locally scoped type alias nodes #23188

weswigham opened this issue Apr 5, 2018 · 11 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@weswigham
Copy link
Member

We mentioned this at a previous design meeting while discussing conditional types but I don't think an issue was opened for it (nor can I find mention of it within some design meeting notes). We think it'd be useful for both conditional types (to make a bare type reference/parameter) and in complex types (to reduce duplication) to enable a kind of type-alias-as-a-type-node syntax. Something that allows rewriting code like this:

type Foo<T, K extends string, TVal extends T[K] = T[K]> = TVal extends string ? {x: TVal} : never;

as

type Foo<T, K extends string> = type TVal = T[K] in TVal extends string ? {x: TVal} : never;

or

type MyBox<T, K extends keyof T = keyof T, TVal extends T[K] = T[K]> = {
  host: T,
  getValue(k: K): TVal,
  setValue(k: K, v: TVal): TVal
};

as

type MyBox<T> = type K = keyof T, TVal = T[K] in {
  host: T,
  getValue(k: K): TVal,
  setValue(k: K, v: TVal): TVal
};

this allows elision of unnecessary information (no need to write both a constraint and identical default), and prevents usages from accidentally providing an extra type parameter when they shouldn't (because the defaulted parameter was used effectively as a const). I believe @bterlson had mentioned wanting something like this in-person, too.

As for proposed syntax, I'd say:

  • A LocalTypeAlias is a TypeNode (so is valid anywhere a TypeNode is).
  • A LocalTypeAlias is parsed as a required type keword, then a comma separated list of LocalTypeAliasAssignments (with at least one element) - the type assignment list, followed by in and an arbitrary TypeNode - the subject of the assignments.
  • A LocalTypeAliasAssignment is an Identifier followed by = followed by a TypeNode. (does this need to be restricted to parse in a human-understandable way?)

For semantics:

  • A LocalTypeAlias is a local scope around its subject type node. It binds a declaration for a type parameter for each identifier in each assignment in its type alias assignment list, constrained to the assigned value, and establishes a TypeMapper to map those type parameters to their assigned value as well (similar to an instantiated generic type alias). (Should they be allowed to be mutually referential? Other parameter lists are, so I don't see why not.) These aliases are made to be type parameters (and not raw aliases like a nongeneric type alias declaration) so they interact favorably with conditional types - eg, they are a way to force a conditional type to iterate over a union for any type node without introducing another top-level alias.

Thoughts?

@weswigham weswigham added Suggestion An idea for TypeScript Discussion Issues which may not have code impact labels Apr 5, 2018
@mhegazy mhegazy added In Discussion Not yet reached consensus and removed Discussion Issues which may not have code impact labels Apr 5, 2018
@kpdonn
Copy link
Contributor

kpdonn commented Apr 6, 2018

I think it's a great idea. I've wanted to propose something like this before but I wasn't able to imagine a workable syntax.

I do think the syntax is a little hard to follow but I'd be willing to accept that in exchange for the benefits you mentioned. I see myself using this all the time.

@kpdonn
Copy link
Contributor

kpdonn commented Apr 6, 2018

I think the difficulties in reading it largely come from two places:

  • The = for the LocalTypeAliasAssignments appear without any visual nesting to separate them from the initial = for the top-level alias.
  • The use of in essentially has to replace the meaning that = has in regular type aliases that don't need any LocalTypeAlias.

You've opened my mind to the possibilities though so if you're open to some bikeshedding, what about:

type Foo<T, K extends string> with <TVal = T[K]> = TVal extends string ? {x: TVal} : never;

and

type MyBox<T> with <K = keyof T, TVal = T[K]> = {
  host: T,
  getValue(k: K): TVal,
  setValue(k: K, v: TVal): TVal
};

It allows visual nesting between the top level = and the = for the LocalTypeAliasAssignments and it keeps the meaning of the top level = the same for both type aliases that need LocalTypeAlias and those that don't.

@weswigham
Copy link
Member Author

@kpdonn I'd like it to be a type node and not an alias variant, so it can be used with embedded conditional types, eg

type MethodReturnMap<T> = {
  [K in keyof T]: type Val = T[K] in Val extends () => infer R ? R : never
}

This way it can always be used to force a conditional type to distribute over unions.

@kpdonn
Copy link
Contributor

kpdonn commented Apr 6, 2018

Okay I can see that, that's even more powerful than I was thinking.

I do have a hard time with really wanting to read the in from type Val = T[K] in Val as somehow similar to the in from [K in keyof T] even though as far as I can tell they are conceptually unrelated, but I'm sure I could get used to it.

Edit: Maybe for instead of in? That seems to read more naturally to me anyway.

@ajafff
Copy link
Contributor

ajafff commented Apr 6, 2018

I like the with proposal. I actually wanted to suggest something similar:

type MethodReturnMap<T> = {
  [K in keyof T]: with(Val = T[K]) { Val extends () => infer R ? R : never }
}

IMO this makes it more readable and may already look familiar due to the similarities to JS with statements (don't know if that's desirable).

@weswigham
Copy link
Member Author

What you have there looks like a block or an object type. If we're drawing analogies to value spaces we'd want something that works like an expression. Think let expressions in Haskell.

@jack-williams
Copy link
Collaborator

Am I right in thinking that semantically the proposal is much like an anonymous type abstraction that is immediately applied? Similar to how let can be encoded at the value level with:

let x = M in N === (x => N) M

then the type level version is:

type X = A in B === (<X>B)<A>

This would be consistent with local aliases causing distribution.

I think the main concern with the syntax would be users getting confused between these two:

type T = number | string;
type Foo<X> = T extends number ? X : never;

type Bar<X> = type T = number | string in T extends number ? X : never;

Is there a reason why let is off the table?

@kpdonn
Copy link
Contributor

kpdonn commented Apr 6, 2018

Thinking more about my personal readability difficulties, I keep having problems with in being the delimiter. I'm not able to see the where the end of the LocalTypeAliasAssignment list is at a glance. And even once I find it my eyes keep wanting to mentally bind the in to just the very last token. Adding parentheses to show how I am mentally grouping it, I keep trying to read it like

type Foo<T, K extends string> = type TVal = ( T[K] in TVal extends string ? {x: TVal} : never )

or

type Foo<T, K extends string> = type TVal = ( T[K] in TVal ) extends string ? {x: TVal} : never 

instead of the correct

type Foo<T, K extends string> = type ( TVal = T[K] ) in ( TVal extends string ? {x: TVal} : never )

Another syntax suggestion

Thinking about that plus @jack-williams concern about the different meanings of type T = number | string depending on the location, what about changing the definition for parsing LocalTypeAlias to

  • A LocalTypeAlias is parsed as a required type keyword, then an opening <, then a comma separated list of LocalTypeAliasAssignments (with at least one element) - the type assignment list, followed by a closing > and then an arbitrary TypeNode - the subject of the assignments.

Examples

type Foo<T, K extends string> = type <TVal = T[K]> TVal extends string ? {x: TVal} : never;
type MyBox<T> = type <K = keyof T, TVal = T[K]> {
  host: T,
  getValue(k: K): TVal,
  setValue(k: K, v: TVal): TVal
};
type MethodReturnMap<T> = {
  [K in keyof T]: type <Val = T[K]> Val extends () => infer R ? R : never
}
type Bar<X> = type <T = number | string> T extends number ? X : never;

Thoughts

That syntax resolves my readability difficulties. It doesn't seem like there would need to be any extra delimiter after the > such as in or for or =. I'm actually not even certain the type keyword is really needed but I left it there.

I think that syntax would also help with @jack-williams comment by making it more obvious that the LocalTypeAliass are type parameters because they'd be declared inside < > brackets the same way other type parameters are. So I think it'd be more clear that

type Bar<X> = type <T = number | string> T extends number ? X : never;

is analogous to

type DistributeHelper<X, T> = T extends number ? X : never;
type Foo<X> = DistributeHelper<X, string | number>

and not analogous to

type T = number | string;
type Foo<X> = T extends number ? X : never;

Finally

Just want to re-emphasize that I like the proposal a lot regardless of syntax. So if you don't think my suggestion is an improvement then I'd still be very happy with the original.

@parzh
Copy link

parzh commented Jul 24, 2019

In that PR (#32525) I've suggested a functionality similar to what's being suggested here. Basically, the idea is to extend syntax of type expressions with where … is … construct, such as this:

type Entries<Obj extends object> =
    Array<[ Key, Obj[Key] ]> where Key is keyof Obj;
type Entries<Obj extends object> =
    Array<[ Key, Value ]> where Key is keyof Obj, Value is Obj[Key];

The full syntax is this ([ … ] is grouping, * is a "zero or more" quantifier):

= {type expression} where {identifier} is {type expression} [ , {identifier} is {type expression} ]*

What do you think about this kind of syntax?

@cefn
Copy link

cefn commented Aug 22, 2020

Thanks to @jack-williams for pointing me to the correct issue. I closed #40194 in favour of this.

I found myself thinking of it as a conceptual combination of Mapped Types and explicit distributive logic. If this is correct, the syntax proposal I sketched using both in and each may help. It leads to type declarations like ...

= {identifier} in {type expression} each {type expression}

The problem case I mentioned in the original issue would therefore read like...

type LoadableKey = K in keyof DataState each DataState[K] extends { isLoading: boolean } ? K: never;

If I followed it correctly, the OP's problem case...

type Foo<T, K extends string, TVal extends T[K] = T[K]> = TVal extends string ? {x: TVal} : never;

...would look like...

type Foo<T,K extends string> = Tval in T[K] each TVal extends string ? {x: TVal} : never;

...or limiting to the constrained, mapped type, union case K in keyof ...

type Foo<T> = K in keyof T each T[K] extends string ? {x: T[K]} : never;

@DanielRosenwasser
Copy link
Member

Related is #41470 which thinks about this in terms of an anonymous namespace so that you can have arbitrary locals.

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

7 participants