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
versusWithSparseData & { data: number[] }
matter ? is this specified, or may future TypeScript compilers change behavior?
CodePudding user response:
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)
whenx
isX
, then it should be valid to writexy.push(z)
whenxy
isX & 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>
, orArray<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.