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

syntax to control over distributivness #30572

Open
zpdDG4gta8XKpMCd opened this issue Mar 24, 2019 · 17 comments
Open

syntax to control over distributivness #30572

zpdDG4gta8XKpMCd opened this issue Mar 24, 2019 · 17 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@zpdDG4gta8XKpMCd
Copy link

zpdDG4gta8XKpMCd commented Mar 24, 2019

from: #30569 (comment)

so basically we need a way to say it clear and loud in the language whether we want:

Promise<A> | Promise<B>

or

Promise<A | B>

as a result of a type operation

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Mar 25, 2019

Proposal: allow wrapping the type in [] to indicate non-distributivity, e.g. write

type C<T> = [T] extends [U] ? V : W;

🤷‍♂️

@zpdDG4gta8XKpMCd
Copy link
Author

zpdDG4gta8XKpMCd commented Mar 25, 2019

this already has some weird meaning: #29662 (comment)

and besides: #9217 (comment)

how about we make some new bold syntax for the type domain like together T and apart T

@jack-williams
Copy link
Collaborator

My ad-hoc comment is a short-term solution for a yet to be confirmed bug. It definitely isn't meaningful.

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Mar 26, 2019
@nevir
Copy link

nevir commented Apr 4, 2019

Would wrapping in () make sense? Conceptually, it's pretty close to operator precedence and how we manage it

@zpdDG4gta8XKpMCd
Copy link
Author

there was some hold back on using (...) syntax for special purposes: #9217 (comment)

not sure why [...] is in favor now

@dragomirtitian
Copy link
Contributor

dragomirtitian commented Apr 5, 2019

@Aleksey-Bykov It's not in favor to control distributiveness, it just happens to do so (and is the simplest syntax for it). [T] is a tuple containing one element of type T just as it would be in any other context. A conditional type distributes only over naked type parameters, since the condition is now on a tuple that contains T it is no longer over a naked type parameter and hence distribution does not happen.

This would happen with any other usage of T , for example this would also prevent distribution:

type NoDistribution<T> = { o: T } extends { o: number } ? "Y" : "N"
type T1 = NoDistribution<number> //Y
type T2 = NoDistribution <number | string> //N

Tuples just happen to be the most concise way to do this. (Also for the type relation to be equivalent to T extends U the usage of T would have to be in a covariant position. A contravariant position, such as a function parameter, would inverse the relation)

I would also be reluctant to assign any special meaning to () in conditionals. () can already be used in types for grouping and they do not add any meaning they just change precedence (as they do in most other context): type A = (readonly number[])[].

@jack-williams
Copy link
Collaborator

Tuples do have special treatment in that they correctly create substitution types in the true branch, effectively doing type narrowing---an object type will not do that. This is why the tuple method is proposed as the canonical way of doing this.

type OnlyNumber<N extends number> = [N];
type NoDistribution<T> = { o: T } extends { o: number } ? OnlyNumber<T> : "N" // error
type NoDistribution2<T> = [T] extends [number] ? OnlyNumber<T> : "N" // no error

I would also be reluctant to assign any special meaning to () in conditionals. () can already be used in types for grouping and they do not add any meaning they just change precedence (as they do in most other context): type A = (readonly number[])[].

Very much agree.

@zpdDG4gta8XKpMCd
Copy link
Author

zpdDG4gta8XKpMCd commented Apr 5, 2019

i totally get it that [...] happened to do the trick, what i am saying is that although it does what we need:

  • it's cryptic
  • non discoverable
  • strangely looking at very least

so when they say, "hey, let's just officially use it", i say there must be a better way

@jack-williams
Copy link
Collaborator

The issue I have is that this really isn't a syntactic problem; the issue is with discoverability of semantics.

People start by seeing the conditional types in the lib such as:

type Exclude<T, U> = T extends U ? never : T;

that seems to just work. Then they try something abit different, or more advance, and they get bitten by using a conditional type that was distributive when it should not have been.

The problem is not how the distinction is represented; the problem is just signalling that such a distinction even exists.

If I were being obtuse I would say that people should just read the handbook before trying advanced stuff. It currently doesn't mention the tuple trick, which I think it should, (Maybe I'll add a PR), but it does explain a lot of the details.

A radical suggestion would be to change conditional types to say that they are always distributive: this is just how they get resolved. In particular, when the check type is a union the conditional type is distributed. This does not happen at instantiation, rather at resolution. The main change would be things like:

type UndefinedIs<T> = (undefined | number) extends T ? true : false;
type A = UndefinedIs<number>; // was false, now boolean

type NeverIs<T> = never extends T ? true : false;
type B = NeverIs<number> // was true, now never;

The same tuple trick would work to prevent distribution because it fixes the check type as a non union. The main difference is now that the behaviour is consistent between substitution (beta reduction of types), and is no longer a property fixed at the declaration. I'm not sure I like this though, and it probably has fundamental problems.

@zpdDG4gta8XKpMCd
Copy link
Author

zpdDG4gta8XKpMCd commented Apr 5, 2019

but why tuples? it looks like a random work of god, somehow tuples is what can save us, and it's a miracle indeed, but now that we know how it was done and why it was done in tuples, why won't we learn the lesson and make it a first class feature that

  • originated with tuples (and we pay it all due respect and all zealots are free to use tuples for it if they think it's a more "true" way)
  • but also now can be done without even mentioning them
    because, let's be clear, tuples are orthogonal to the problem of distributivness

because if we don't, and stick with tuples, it will give tuples a superpower that they do not deserve, and in a long run we will regret this

9 cages and 10 pigeons, let's make another cage

@SanzSeraph
Copy link

SanzSeraph commented Mar 26, 2020

TL;DR;

Distributive conditional types seem very similar conceptually to mapped types. Perhaps the syntax for distributive conditional types could be improved by adopting a more general syntax for mapping union types to create other union types that borrows from the mapped types in operator. (scroll to the proposal section for more detail on this.)

Long Version

After a lot of searching to make sure I wasn't duplicating an existing suggestion, I came across this issue. Hopefully this is the appropriate place to post. I'm not sure I have a great suggestion for how to improve distributive conditionals but I'll take a stab at it. I also don't have much in the way of formal computer science training, so forgive me if I come off as a bit sophomoric.

I was reading this article in an attempt to implement strongly typed event emitters. After reading through the Advanced Types section of the Handbook, I was finally able to understand this type alias:

export type MatchingKeys<
  TRecord,
  TMatch,
  K extends keyof TRecord = keyof TRecord
> = K extends (TRecord[K] extends TMatch ? K : never) ? K : never;

First, it's not clear to me why a naked type parameter should be distributive while a wrapped type parameter is not. If the parenthesized conditional type was distributive, then there would be no need for nested conditional types in this type alias. TRecord[K] could be resolved to the type of each of the constituents of K when applied to TRecord, and MatchingKeys<TRecord, TMatch> would represent a type whose properties each have a type of TMatch.

Second, the parentheses imply that the inner conditional type will be resolved first, at which point the outer conditional type will be distributive over either K or never, depending on the result of evaluating that inner conditional type.

Third, it seems to me that the syntax for mapped and distributive conditional types represent a redundancy and an opportunity to improve the latter by adopting something similar to the former. Conceptually, mapping the members of a type to members of a new type seems very similar to distributing a conditional type over a union.

Proposal

What if TypeScript instead had a more generalized way to map over the constituents of a union to produce a new union? The in operator could be overloaded to define a new type parameter that represents a constituent of the union. Modifying an example from the Handbook:

type BoxedValue<T> = { value: T };
type Boxed<T, M in T> = BoxedValue<M>

type T20 = Boxed<string | number>; // BoxedValue<string> | BoxedValue<number>

Even if T is not a union, TypeScript could treat it like a union consisting of one constituent, so this generic type alias could work for non-union types.

Conditional distributive types would then become just a special case of this new "distributive type" concept. Again modifying an example from the Handbook:

type BoxedValue<T> = { value: T };
type BoxedArray<T> = { array: T[] };
type Boxed<T, M in T> = M extends any[] ? BoxedArray<M[number]> : BoxedValue<T>;

type T22 = Boxed<string | number[]>;  // BoxedValue<string> | BoxedArray<number>;

The example from the article cited above could then be dramatically simplified:

export type MatchingKeys<
  TRecord,
  TMatch,
  K extends keyof TRecord = keyof TRecord,
  M in K
> = TRecord[M] extends TMatch ? M : never;

This makes it explicit that M does not represent the union, but each constituent of the union.

@Jamesernator
Copy link

Jamesernator commented Apr 8, 2022

(Unexpected) Distributivity of conditional types is something that bit me fairly strongly today, the fact some features are just magically distributive seems fairly confusing design in my opinion.

Like the main reason distributivity is wanted is so that people can specify things like Boxed<a | b> === Box<a> | Box<b>, but that seems more of a failure of the language to provide a way to specify a list of types to be distributed later.

Consider arrays, Array<A | B> is not Array<A> | Array<B>, the fact some generics just magically distribute seems more of an confusing feature than a predictable one. It'd be more obvious if there was just a way to declare a distributed union for any generic, i.e. maybe Array<A || B> === Array<A> | Array<B>. People then wouldn't even need to define the wrappers, they could just || wherever they want spreading over a type constructor.

@saltman424
Copy link

I can handle the confusion around distributiveness in conditional types, but how am I supposed to control distributiveness in mapped types?

Consider the following case:

type Union = { foo: 1 } | { foo: 2 }
type Getters<T> = { [K in keyof T]: () => T[K] }
type ActualFooGetter = Getters<Union>['foo'] // (() => 1) | (() => 2)
type DesiredFooGetter = () => Union['foo'] // () => 1 | 2
// How do I get Getters<Union> to be { foo: () => Union['foo'] }?

Any suggestions would be much appreciated.

Also, I don't see anything in the documentation explaining that mapped types are distributive, so that would be a welcome addition. Although, maybe I am just looking in the wrong spot.

As a side note, I really like the proposal from @SanzSeraph. Essentially a special case of a local type alias (e.g. as described in #41470) that would be used to create distributive types. I think that would make distributiveness way more accessible for most developers

@dragomirtitian
Copy link
Contributor

dragomirtitian commented May 11, 2023

@saltman424 Distributivity for mapped types requires mapping over directly keyof T or a type parameter K that has a constraint of keyof T. If you don't map directly over those, distributivity is disable:

type Union = { foo: 1 } | { foo: 2 }
type Getters<T> = { [K in keyof T & {}]: () => T[K] }
type ActualFooGetter = Getters<Union>['foo'] // () => 1 | 2
type DesiredFooGetter = () => Union['foo'] // () => 1 | 2?

Playground Link

@saltman424
Copy link

@dragomirtitian thank you!! That is really helpful to know. Do you know if that is anywhere in the documentation? If not, that would be a great addition.

@jedwards1211
Copy link

jedwards1211 commented Nov 10, 2023

I'm going to be the grouch here, even though I've understood distributive conditional types and how to defeat distribution with [ ] for a long time, this still drives me up a wall.

Mostly, I do love TypeScript. Having some way of distributing is definitely better than nothing. But having an awkward way really gets in the way of me completely loving it. (And it's sad to see that Flow copied the same design instead of coming up with a cleaner one...)

It flies in the face of all common sense that if you have type A<T> = ..., you can't substitute the right-hand side in place of the left hand side to determine the computed type. We're used to substitution in algebra, or in lambda calculus; it's unusual that type aliases don't follow this same substitution logic. They can't even properly be called type aliases! Because they do a lot more than simply creating an alias for another type. This is my gripe with tuple mapping as well. Type aliases shouldn't do anything special!

But aside from taste, the objective drawback is that it's bound to confuse all newcomers to the language. You can't tell me anyone understood what the brackets in [T] extends [number] meant the first time they saw it. Or that it was obvious they should use the bizarre extends any ? when they need to unconditionally distribute.

So a more obvious syntax would be a huge win for everyone, not just for crotchety devs like me who can't get over their raging pet peeves about it.

@jedwards1211
Copy link

jedwards1211 commented Nov 10, 2023

There should be an inline syntax for distributing over a union anywhere, not just in a type alias parameter declaration like @SanzSeraph suggested.

const x: { foo: T in 1 | 2 | 3 as { value: T } } = ...

This would increase everyone's productivity. Having to define a type alias and pick a name for it interrupts your train of thought.

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

9 participants