Skip to content

syntax to control over distributivness #30572

Open

Description

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

Activity

  1. RyanCavanaugh commented on Mar 25, 2019

    @RyanCavanaugh
    Member

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

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

    🤷‍♂️

  2. zpdDG4gta8XKpMCd commented on Mar 25, 2019

    @zpdDG4gta8XKpMCd
    Author

    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

  3. jack-williams commented on Mar 25, 2019

    @jack-williams
    Collaborator

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

  4. nevir commented on Apr 4, 2019

    @nevir

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

  5. zpdDG4gta8XKpMCd commented on Apr 5, 2019

    @zpdDG4gta8XKpMCd
    Author

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

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

  6. dragomirtitian commented on Apr 5, 2019

    @dragomirtitian
    Contributor

    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[])[].

  7. jack-williams commented on Apr 5, 2019

    @jack-williams
    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.

  8. zpdDG4gta8XKpMCd commented on Apr 5, 2019

    @zpdDG4gta8XKpMCd
    Author

    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

  9. jack-williams commented on Apr 5, 2019

    @jack-williams
    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.

  10. zpdDG4gta8XKpMCd commented on Apr 5, 2019

    @zpdDG4gta8XKpMCd
    Author

    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

  11. SanzSeraph commented on Mar 26, 2020

    @SanzSeraph

    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.

  12. Jamesernator commented on Apr 8, 2022

    @Jamesernator

    (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.

  13. saltman424 commented on May 11, 2023

    @saltman424

    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 Daniel Arant (@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

  14. dragomirtitian commented on May 11, 2023

    @dragomirtitian
    Contributor

    Sander Altman (@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

  15. saltman424 commented on May 11, 2023

    @saltman424

    Titian Cernicova-Dragomir (@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.

  16. jedwards1211 commented on Nov 10, 2023

    @jedwards1211

    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.

  17. jedwards1211 commented on Nov 10, 2023

    @jedwards1211

    There should be an inline syntax for distributing over a union anywhere, not just in a type alias parameter declaration like Daniel Arant (@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

Metadata

Metadata

Assignees

No one assigned

    Labels

    In DiscussionNot yet reached consensusSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions