arraystypescripttype-inferenceunion-typesintersection-types

TypeScript array intersection type: property does not exist when accessed in Array.forEach, Array.some, etc, but accessible within for loop


So, I might not have the right terminology to describe this problem, which made searching for it tricky, but I hope my example helps clear it up.

Context: I'm using query data selectors in react-query to preprocess query results and attach some properties that I need globally in the application. I'm running into an issue with the produced types, which I managed to narrow down and reproduce outside of react-query itself.

Here's the reproduction code (also on TS Playground)

// Data structure coming from third-party code (I have no control over it)
// Simplified for a more concise reproduction:
type DataStructure =
  & Array<{ a: number; b: number }>
  & Array<{ b: number; c: number }>

const structure: DataStructure = [
  {
    a: 1,
    b: 2,
    c: 3,
  },
]

structure[0].a // ok
structure[0].b // ok
structure[0].c // ok

for (const s of structure) {
  s.a // ok
  s.b // ok
  s.c // ok
}

structure.forEach(s => {
  s.a // ok
  s.b // ok
  s.c // Property 'c' does not exist on type '{ a: number; b: number; }'.(2339)
})

// If we had control over DataStructure, we'd write it like this instead:
// Array<{ a: number; b: number } & { b: number; c: number }>

The DataStructure is an intersection type of two Arrays with partially overlapping item types.

As demonstrated by the code and the comments, all 3 properties are available when the array items are accessed either by their index or inside a for loop, but inside a forEach loop (or any array method like some, every, etc,) only properties of the first array type (from the intersection) are available.

Trying to access c inside the forEach loop, TypeScript complains:

Property 'c' does not exist on type '{ a: number; b: number; }'.(2339)

Now, if I had control over the data structure definition, I'd describe it like this:

type DataStrcture = Array<{ a: number; b: number } & { b: number; c: number }>

That would indeed solve the issue. But I don't have control over that part of the code.

What I'm more interested in is understanding WHY TypeScript behaves the way it does here. That's what's baffling me the most, but if someone can offer a clean solution, too, that'd be extra amazing!


Solution

  • This is considered a design limitation of TypeScript. Intersections of array types behave strangely and are not recommended. See microsoft/TypeScript#41874 for an authoritative answer. It says:

    Array intersection is weird since there are many invariants of arrays and many invariants of intersections that can't be simultaneously met. For example, if it's valid to call a.push(x) when a is A, then it should be valid to write ab.push(x) when ab is A & B, but that creates an unsound read on (A & B)[number].

    In higher-order the current behavior is really the best we can do; in zero-order it's really preferable to just write Array<A & B>, Array<A> | Array<B>, or Array<A | B> depending on which you mean to happen.

    Some of the weirdness is due to the fact that intersections of functions and methods behave like overloads, and arrays are unsafely considered covariant in their element type (see Why are TypeScript arrays covariant? ), which means you suddenly have the situation with push() as described above.

    Other weirdness happens when you try to iterate through them, as you've shown, and as described in microsoft/TypeScript#39693.


    So the recommended approach is to avoid intersections of arrays, and instead use arrays of intersections if that's what you want. If you can write that out directly, you should. If you have a type with nested intersected arrays you can look at Why does the merge of 2 types with a shared property name not work when making a type with that property from the merged type? for a possible approach to writing a utility type to deal with those.

    As I mentioned in the comment, if you can't control the data type, you should show this to whoever does so they can fix it. Otherwise, you'll need to work around it by translating between the external type definition and your fixed version of it.