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

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

Open
geoffreytools opened this issue Mar 19, 2023 · 13 comments
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Milestone

Comments

@geoffreytools
Copy link

geoffreytools commented Mar 19, 2023

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]

@MartinJohns
Copy link
Contributor

Sounds like a duplicate of #50346.

@geoffreytools
Copy link
Author

geoffreytools commented Mar 19, 2023

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.

@Andarist
Copy link
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 )

@geoffreytools
Copy link
Author

geoffreytools commented Mar 20, 2023

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.

@MartinJohns
Copy link
Contributor

Tuples are not objects.

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

@geoffreytools
Copy link
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.

@fatcerberus
Copy link

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.

@geoffreytools
Copy link
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.

@geoffreytools
Copy link
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.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Experimentation Needed Someone needs to try this out to see what happens labels Mar 21, 2023
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Mar 21, 2023
@RyanCavanaugh
Copy link
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.

@geoffreytools
Copy link
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.

@Andarist
Copy link
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

@mkantor
Copy link
Contributor

mkantor commented Apr 28, 2024

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
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

6 participants