Skip to content

The intersection of a tuple with an unrelated array is handled inconsistentlyΒ #53355

Open

Description

Bug Report

πŸ”Ž Search Terms

tuple, intersection, array, unrelated, narrowing, index signature

πŸ•— Version & Regression Information

This is the behavior in every version I tried

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

/* unrelated array */

type ShouldBeNever = string[] & [1, 2, 3]

type OK = (ShouldBeNever)[number] // never

type NotOK = (ShouldBeNever)[1] // 2

type WeirdOverload = (ShouldBeNever)['pop'] // {
//    (): string | undefined
//    (): 3 | 1 | 2 | undefined
//}

const isString = ([1, 2, 3] as ShouldBeNever).pop() // string | undefined


/* related array but not a supertype */

type ShouldBeNarrowed = [number | string, number | boolean] & (string | boolean | symbol)[]

type FF = ShouldBeNarrowed[number] // string | boolean
type HH = ShouldBeNarrowed[0] // string | number

πŸ™ Actual behavior

The index signature is appropriately updated, but not the individual elements nor the methods operating on the resulting tuple. Additionally, methods get overloaded, so when calling pop for example, the first overload is selected and the return type is not the intersection of the original components return types as it should be.

πŸ™‚ Expected behavior

I would expect ShouldBeNever to be [never, never, never] and ShouldBeNarrowed to be [string, boolean]

Activity

  1. MartinJohns commented on Mar 19, 2023

    @MartinJohns
    Contributor

    Sounds like a duplicate of #50346.

  2. geoffreytools commented on Mar 19, 2023

    @geoffreytools
    Author

    I think it's a distinct issue.

    My conclusion that ShouldBeNever should be [never, never, never] could be misleading. I would be "perfectly happy" with it being [never, never, never] & { [k: number]: never }. What matters is that the values are actually updated.

    In any case, it is not logical that ShouldBeNever[1] is 2 but that methods keep mentioning string. There is a problem here (by the way I am aware that using pop on a tuple is funny, but the function signature was short).

    I am removing BothNumber and AndString from the repro because this is actually normal.

  3. Andarist commented on Mar 20, 2023

    @Andarist
    Contributor

    I believe that this is the same problem as here:

    type Obj = { a: number } & { [key: string]: string }
    type Prop = Obj["a"] 
    //   ^? number

    It's just that accessing a property on an intersection prefers types of concrete properties over the types from index signatures. So no conflict here is detected (even though conceptually it is a problem and such intersections are kinda invalid but at the same time... people are using that kind of an intersection as a workaround for #17867 )

  4. geoffreytools commented on Mar 20, 2023

    @geoffreytools
    Author

    I think the two shouldn't be conflated because we have sound open-ended tuples ([1, 2, ...string[]]). There is no need to hack the index signature in order to accept other elements.

    Tuples are not objects. They are ordered, they have a set number of elements, etc. I don't think they can be handled the same way as objects.

    As it stands, users are forced to do special casing in order to deal with tuples so that TS doesn't have to. I think it should be the other way round, especially given that users actually can't do it properly. For instance, there is to my knowledge no way to infer the rest elements of an open-ended tuple which has been intersected with an array. The information is lost.

    Intersections are sometimes unavoidable.

    When I encounter an intersection between a tuple and unknown[] for example, the tuple is often the return value of some complex utility type and the intersection prevents TS from spinning in infinite recursion trying to index it or whatnot. Some other times TS simply didn't pickup that it was dealing with a tuple and, similarly, the intersection silenced an error. In some cases you can use a conditional type instead of an intersection, but it is not always possible.

    In general I don't think people typically intersect arrays in ways that are abusive.

    Since tuples are a subtype of arrays and unknown[] is the neutral element of intersection with an array I would expect that property to hold with a tuple, and the same thing would apply to different subtypes like number[].

    Similarly, when I intersect two arrays or two tuples, elements and index signatures get intersected element-wise and the index-signature reflects the content of the elements of the tuple, so I would expect to be able to mix and match and have a predictable outcome.

    Edit. Gee, actually this is not true

    and the index-signature reflects the content of the elements of the tuple

    type A = [number|string, boolean|symbol] & [string|symbol, number|boolean];
    
    type B = [A[0], A[1]] // [string, boolean]
    
    type C = A[number] // string | number | boolean | symbol

    Honestly this is embarrassing.

  5. MartinJohns commented on Mar 20, 2023

    @MartinJohns
    Contributor

    Tuples are not objects.

    But tuples are objects, just as arrays (and tuples are just arrays).

  6. geoffreytools commented on Mar 20, 2023

    @geoffreytools
    Author

    I understand why you say that but I think you are conflating the abstract structure and its concretion in a data-structure.

    You can model a matrix with a TypedArray, but matrices are not typed arrays: they have the concept of dimensions which is lacking in typed arrays and which would be lost if you didn't special-case operations specifically for matrices when dealing with them.

    Tuples are not "just arrays", they have the concept of ordering and length β€” and with greater reason they are not just objects. TS already acknowledges this: you can express a non-empty tuple with a type definition [unknown, ...unknown[]] but you can't express a non-empty object with a type definition. They are distinct abstract structures with different operations and different invariants. Using object intersection on tuples can't magically enforce these invariants.

    When you use + on 2 strings you concatenate the 2 strings, you don't add each character element-wise. The operator + is simply overloaded with a different behaviour. Now don't get me wrong, you still have a closed binary associative operation with a neutral element and you can come up with an explanation as to why it is logical to use the same symbol for both, so the two + operations are related but they are not identical.

  7. fatcerberus commented on Mar 21, 2023

    @fatcerberus

    I understand why you say that but I think you are conflating the abstract structure and its concretion in a data-structure.

    This is unavoidable because the TS type system primarily aims to model the primitives that exist in JS at runtime. It doesn't usually speak in terms of higher-level abstractions, and when it does it's mostly sleight-of-hand; e.g. even Array is ultimately just a generic interface with a numeric index signature combined with some special handling in the compiler to make it seem "special" vs. the equivalent object type, but the illusion is easily broken, e.g. TS has no concept that checking the .length of an array is a bounds check, often leading to bogus errors under noUncheckedIndexedAccess. As far as the compiler is concerned .length has no more meaning than it does in any other random object. And then tuple types add more magic on top of that. It's not always ideal, but that's the way it is.

  8. geoffreytools commented on Mar 21, 2023

    @geoffreytools
    Author

    I understand what you say, but tuples are required internally by TS to handle function arguments and a bunch of other stuff in type-level programming, so it is very much a TS construct, not only a JS primitive.

  9. geoffreytools commented on Mar 21, 2023

    @geoffreytools
    Author

    I also want to emphasis the lack of consistency between array & tuple and tuple & tuple. In the former case the index signature is updated but not the elements and in the latter case the elements are updated but not the index signature. This is super confusing.

  10. RyanCavanaugh commented on Mar 21, 2023

    @RyanCavanaugh
    Member

    It seems like we have competing invariants here.

    The situation is exactly analogous to the thing that is already idiomatic today in string-key-land:

    type P = { x: number, y: number }
    type PR = P & { [s: string]: boolean }
    declare const pr: PR;
    
    const n = pr.x; // number
    const s = pr["x" as string]; // boolean

    except that for tuples, "x" and "y" are 0 and 1.

    So we could special-case tuples to do something else, but now there's an inconsistency that string key / string index signature and numeric key / numeric index signature intersections behave differently.

    I'm sympathetic to the case that array/tuple intersections are used differently in practice. If someone wants to put together a PR that presents a cohesive case for unifying this in a way that's unambiguously better, and doesn't cause much negative churn in real projects, I think that's something we could consider.

    That said, the behavior is consistent with other interactions of declared-property and index-signature property access, and as such I don't consider this a defect, this all having been the case for something like 6+ years now with this report being the first or maybe second case where someone truly objected to the behavior is it stands.

  11. geoffreytools commented on Mar 21, 2023

    @geoffreytools
    Author

    I think there already are trivial inconsistencies between tuples and objects regarding index signatures, simply because tuples come with an index signature to begin with and it is considered during intersection:

    type P = [number, number]
    type PR = P & { [s: number]: boolean }
    declare const pr: PR;
    
    const n = pr[0]; // number
    const s = pr[0 as number]; // never
    //                            -----
    

    Another example is the bounded indexation of tuples:

    type A = [1, 2, 3];
    type B = A[number] // 1 | 2 | 3
    type C = A[4] // error
    //               -----

    This is consistent with objects in terms of the observable behaviour (can't access a key that isn't there) but it is not consistent in terms of the underlying rules because an index signature is actually there but it doesn't make the promise that every possible numeric key has a value.

    Intersecting the tuple with its own index signature and see the behaviour change kind of establishes that tuples are not objects:

    type A = [1, 2, 3] & { [k: number]: 1 | 2 | 3 };
    type B = A[number]
    type C = A[4] // 1 | 2 | 3
    //               ---------

    I think people are pretty tolerant concerning tuples because you have been adding feature after feature, enabling their use in more and more places and fixing issues over time. I personally consider their implementation in the language a work in progress and I don't share the "that's the way it is" attitude of my fellow commenters because they still have problems (and there are other tuple-related issues).

    I appreciate the open-mindedness and sympathy.

  12. Andarist commented on Mar 22, 2023

    @Andarist
    Contributor

    The situation is exactly analogous to the thing that is already idiomatic today in string-key-land:

    Is this one idiomatic though? I know that this is how it works but I always considered it to be a defect/an implementation byproduct. The idiomatic invariant (to me) would be for those two to always behave the same way:

    type Intersected = { concrete: string } & { [key: string]: number }
    type MyType = { concrete: string; [key: string]: number }

    But the second is not allowed (for good reasons).

    this all having been the case for something like 6+ years now with this report being the first or maybe second case where someone truly objected to the behavior is it stands.

    To add some context, I've seen people running into this problem with tuples on multiple occasions. Often they just don't report this stuff, OTOH - the reported issues are a metric of how popular of a feature request this is... so we could definitely also say that it's not that popular of a request.

    I've seen people trying to start with readonly unknown[] (to satisfy constraints etc) and then trying to "narrow" it down through conditional types etc using intersections. This often breaks assumptions, for example, inferFromObjectTypes is not able to infer from such an intersection because an intersection doesn't pass isArrayOrTupleType check. That being said, this particular issue could probably be treated as a separate one - we don't have to eagerly reduce~ such intersections to be able to infer from them within inferFromObjectTypes

  13. mkantor commented on Apr 28, 2024

    @mkantor
    Contributor

    Someone in the TypeScript Community Discord server brought up some odd order-dependent behavior when inferring out of intersected array types, which I think is ultimately due to the method overloads that get conjured up (as mentioned in this issue's description).

    Anyway, while mulling it over I sketched up a bunch of potentially-surprising/inconsistent-seeming scenarios which could be worth further consideration.

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

    Experimentation NeededSomeone needs to try this out to see what happensSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions