microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.63k stars 12.44k forks source link

Support locally scoped type alias nodes #23188

Open weswigham opened 6 years ago

weswigham commented 6 years ago

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:

For semantics:

Thoughts?

kpdonn commented 6 years ago

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 commented 6 years ago

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

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 commented 6 years ago

@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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

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 commented 5 years ago

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 commented 4 years ago

Thanks to @jack-williams for pointing me to the correct issue. I closed https://github.com/microsoft/TypeScript/issues/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 commented 3 years ago

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