Search code examples
typescripttyping

Does TypeScript reconciliate the intersection of a sparse array type and non-sparse array type?


Problem Statement

The TypeScript 4.8 static type inference/check seems to be inconsistent:

type A = number[] & (number|undefined)[];
type B = (number|undefined)[] & number[];

function arrayAccess_A(a: A) { return a[0]; }  // returns a 'number'
function arrayAccess_B(b: B) { return b[0]; }  // returns a 'number'

function mapFirst_A(a: A) { return a.map(it => it)[0]; }  // returns a 'number'
function mapFirst_B(b: B) { return b.map(it => it)[0]; }  // returns a 'number | undefined'

I find it odd that one of the methods gives a different return type, what is the reason?

Previous question text (obsolete)

(You may stop reading here, what follows is the content from my original post, after some feedback from jcalz I felt that it was not very clear.)

I try to declare that a sometimes sparse array field { data: (number|undefined)[] } of some type WithSparseData is now not sparse, using type intersection WithSparseData & { data: number[].

// type with a sparse array field
type WithSparseData = {
  data: (number | undefined)[];
  moreData: any;
};

// two ways trying to express that the array field is no longer sparse
type WithContiguousData_A = { data: number[] } & WithSparseData;
type WithContiguousData_B =                    WithSparseData & { data: number[] };

// rightfully rejected by the static type checker
const assignFirstToNotNull_1 = ( numbers: WithSparseData ): { first: number } => ({ first: numbers.data[0] });
const mapAllToDouble_1       = ( numbers: WithSparseData ) => ({...numbers, data: numbers.data.map(it => 2*it) });

// accepted by the static type checker
const assignFirstToNotNull_A = ( numbers: WithContiguousData_A ): { first: number } => ({ first: numbers.data[0] });
const assignFirstToNotNull_B = ( numbers: WithContiguousData_B ): { first: number } => ({ first: numbers.data[0] });

// also accepted by the static type checker
const mapAllToDouble_A       = ( numbers: WithContiguousData_A ) => ({...numbers, data: numbers.data.map(it => 2*it) });

// rejected by the static type checker !!!
const mapAllToDouble_B       = ( numbers: WithContiguousData_B ) => ({...numbers, data: numbers.data.map(it => 2*it) });
                                                                       // 'it' is possibly 'undefined'.ts(18048) ^^

In particular, the type of it in line 23 is inferred to be undefined | number, whereas the first in line 17 is inferred to be number; although both are computed from WithContiguousData_B, the inferred type differs.

Questions:

  • is this a bug of the static type checker?
  • should the order { data: number[] } & WithSparseData versus WithSparseData & { data: number[] } matter ? is this specified, or may future TypeScript compilers change behavior?

Solution

  • As you have seen, intersections of array types do not behave as most people would expect them to. This is effectively a limitation of TypeScript; see microsoft/TypeScript#41874 for an authoritative answer.


    Intersections of non-function types are generally not order-dependent

    This is the behavior most people expect from intersections, where X & Y is equivalent to Y & X. For example, when you index into an intersection of arrays with a number key, you get the order-independent intersection behavior you probably expect. Let's use your A and B types

    type A = number[] & (number | undefined)[];
    type B = (number | undefined)[] & number[];
    

    and see what you get when you index into them with a numeric key:

    type ANumber = A[number];
    // type ANumber = number
    
    type BNumber = B[number];
    // type BNumber = number
    

    An array type Array<T> has a numeric index signature whose property type is T. Thus Array<number>[number] is number, while Array<number | undefined>[number] is number | undefined. And for both A and B, the numeric index signature gives you the intersection of those: (number) & (number | undefined) or (number | undefined) & (number), which is number in both cases.

    That's responsible for the behavior of the arrayAccess_ functions:

    function arrayAccess_A(a: A) { return a[0]; }  // returns a 'number'
    function arrayAccess_B(b: B) { return b[0]; }  // returns a 'number'
    

    That all makes sense, I hope.


    Intersections of function types are generally order-dependent

    On the other hand, TypeScript treats an intersection of function types as a single overloaded function with multiple call signatures. And overloaded function behavior can depend on the order of the call signatures, since they are resolved in order. Contrast the behavior of the following overloaded functions:

    declare function foo(x: string): number;
    declare function foo(x: string): string;
    
    const fooResult = foo("abc"); // number (not string)
    fooResult.toFixed();
    
    declare function bar(x: string): string;
    declare function bar(x: string): number;
    
    const barResult = bar("abc"); // string (not number)
    barResult.toUpperCase();
    

    Both foo() and bar() have two call signatures which accept a string, where one call signature returns a string and the other returns a number. When you call foo(), the compiler resolves the call with the number-returning signature, because it comes first. But when you call bar(), the compiler resolves the call with the string-returning signature, because it comes first.

    For overloads this order-dependence probably seems natural, but it might be surprising to see the same behavior with intersections of functions:

    declare const foo: ((x: string) => number) & ((x: string) => string);
    
    const fooResult = foo("abc"); // number (not string)
    fooResult.toFixed();
    
    declare const bar: ((x: string) => string) & ((x: string) => number);
    
    const barResult = bar("abc"); // string (not number)
    barResult.toUpperCase();
    

    As you can see, an intersection of functions behaves identically to the equivalent overload.

    So now let's look at the map method of your A and B types in turn:

    type AMap = A['map'];
    /* type AMap = (
         <U>(cb: (val: number, idx: number, arr: number[]) => U, ths?: any) => U[]
       ) & (
         <U>(cb: (val: number | undefined, idx: number, arr: (number | undefined)[]) => U, 
           ths?: any) => U[]
       ) 
    */
    
    type BMap = B['map'];
    /* type BMap = (
         <U>(cb: (val: number | undefined, idx: number, arr: (number | undefined)[]) => U, 
           ths?: any) => U[]
       ) & (
         <U>(cb: (val: number, idx: number, arr: number[]) => U, ths?: any) => U[]
       ) 
    */
    

    So for the A type, the map method is an intersection of function types where the first call signature's callback accepts a number value, while in the B type, the map method is an intersection of function types where the first call signature's callback accepts a number | undefined value.

    And that is directly responsible for the following behavior:

    function mapFirst_A(a: A) { return a.map(it => it)[0]; }  // returns a 'number'
    function mapFirst_B(b: B) { return b.map(it => it)[0]; }  // returns a 'number | undefined'
    

    So that's why it happens. Intersections of functions acting like overloads is often desirable. To be clear, nobody likes how this happens when you intersect array types, but according to the comment by the TS team dev lead, it's the best they can do (paraphased slightly):

    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 x.push(z) when x is X, then it should be valid to write xy.push(z) when xy is X & Y, but that creates an unsound read on (X & Y)[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<X & Y>, Array<X> | Array<Y>, or Array<X | Y> depending on which you mean to happen.

    They suggest that instead of using intersections of array types, you use something else that more closely represents your intent. How do do that is beyond the scope of this question, though.

    Playground link to code