Problem Statement
#50875: Spread operator results in incorrect types when used with tuples was filed on TypeScript in September of 2022.
It states that when trying to use a ...
spread operator on a tuple type (a type representing a fixed-size array), TypeScript slips up trying to understand what type the result would be.
That issue’s original code is pretty gnarly and has a lot to read through. By removing unnecessary code I was able to trim it down to three important lines of code:
function test<N extends number>(singletons: ["a"][], i: N) {
const singleton = singletons[i];
// ^? ["a"][][N]
const [, ...rest] = singleton;
// ^? Actual: "a"[]
// Expected: []
}
Here’s a TypeScript playground of the bug report. Walking through the code:
- The type of
singletons
is an array of any size, where each element in the array is["a"]
- It could be:
[ ["a"] ]
, or[ ["a"], ["a"] ]
, or[ ["a"], ["a"], ["a"] ]
, etc.
- It could be:
- The type of
singleton
should be["a"]
: what you’d get by accessing any elementi
([N]
) undersingletons
’s type (["a"][]
) - The type of
rest
is what you get if you remove the first element from the tuple["a"]
, which amounts to no elements ([]
)
…so if rest
is supposed to be type []
, why is it somehow "a"[]
?
Something was going wrong with TypeScript’s type checker.
Spoiler: here’s the resultant pull request. ✨
Playing with Type Parameters
Interestingly, if we change the i
parameter’s type from N
to number
, rest
’s type is correctly inferred as []
:
function test(singletons: ["a"][], i: number) {
const singleton = singletons[i];
// ^? ["a"]
const [, ...rest] = singleton;
// ^? []
}
You can play with a TypeScript playground of the working non-generic number
.
We can therefore deduce that the problem is from a generic type parameter being used to access an element in a tuple type. Interesting.
Playing with Rests
I also played around with the reproduction by removing the ...
rest from the type.
That got a type error to occur, as it should have:
function test<N extends number>(singletons: ["a"][], i: N) {
const singleton = singletons[i];
// ^? ["a"][][N]
const [, rest] = singleton;
// ~~~~
// Tuple type '["a"]' of length '1' has no element at index '1'.
}
So TypeScript was still able to generally understand that singleton
’s type is ["a"]
.
We can therefore further deduce that the problem is from a generic type parameter being used to access a ...
spread of rest elements in a tuple type.
Very interesting.
Digging Into The Checker
At this point I wasn’t sure where to go. I’d never worked in the parts of TypeScript that deal with rests and spreads. Nor had I dared try to touch code areas dealing with generic type parameters and type element accesses.
I did, however, know that getTypeOfNode
is the function called when TypeScript tries to figure out the type at a location (it’s the main function called by checker.getTypeAtLocation
).
I put a breakpoint at the start of getTypeNode
, then ran TypeScript in node --inspect-brk
mode on the bug report’s code in the VS Code debugger.
My goal was to try to find where TypeScript tries to understand the [N]
access of the ["a"][]
type.
The call stack steps inside have a lot of nested function calls. If you have the time, I’d encourage you to pop TypeScript into your own VS Code debugger and follow along.
isDeclarationNameOrImportPropertyName
evaluates totrue
, so TypeScript calls to…getTypeOfSymbol
:symbol.flags & (SymbolFlags.Variable | SymbolFlags.Property)
is true, sogetTypeOfVariableOrParameterOrProperty
is called, which calls to…getTypeOfVariableOrParameterOrPropertyWorker
:ts.isBindingElement(declaration)
is true, so TypeScript calls to…getWidenedTypeForVariableLikeDeclaration
: which calls to…getTypeForVariableLikeDeclaration
:isBindingPattern(declaration.parent)
istrue
, so TypeScript calls to…getTypeForBindingElement
:checkMode
isCheckMode.RestBindingElement
andparentType
does exist.- Calling
typeToString(parentType)
produces'["a"][][N]'
. - Because
parentType
exists, TypeScript calls to…
- Calling
getBindingElementTypeFromParentType
: which seems to be the kind of get an element based on the parent type code logic I’m looking for
I eventually stepped into the following block of code within getBindingElementTypeFromParentType
function:
// If the parent is a tuple type, the rest element has a tuple type of the
// remaining tuple element types. Otherwise, the rest element has an array type with same
// element type as the parent type.
type = everyType(parentType, isTupleType)
? mapType(parentType, (t) => sliceTupleType(t as TupleTypeReference, index))
: createArrayType(elementType);
everyType(parentType, isTupleType)
was evaluating to false
.
Which feels wrong: the parentType
, ["a"][][N]
, should be a tuple type!
Accessing any element of ["a"][]
should give back ["a"]
, a tuple of length 1.
Resolving Base Constraints
At this point I think I understood the issue.
TypeScript was checking whether the parentType
is a tuple type (or is a union of tuple types: hence the everyType(...)
).
But since parentType
referred to a generic type parameter, isTupleType
was returning false
.
What the code should have been doing was resolving the base constraint of the parent type.
Knowing that the type parameter N extends number
means that ["a"][][N]
should always result in an ["a"]
tuple.
I searched for /base.*constraint/
to try to find how TypeScript code resolves base constraints.
A function named getBaseConstraintOfType
showed up a bunch of times.
I changed the code to use getBaseConstraintOfType(parentType)
for retrieving a parent type:
// If the parent is a tuple type, the rest element has a tuple type of the
// remaining tuple element types. Otherwise, the rest element has an array type with same
// element type as the parent type.
const baseConstraint = getBaseConstraintOrType(parentType);
type = everyType(baseConstraint, isTupleType)
? mapType(baseConstraint, (t) =>
sliceTupleType(t as TupleTypeReference, index)
)
: createArrayType(elementType);
…and, voila! Running the locally built TypeScript showed the original bug was fixed. Nice!
Adding Tests
I added the original bug report as a test case: (tests/cases/compiler/spreadTupleAccessedByTypeParameter.ts
).
Then upon running tests and accepting new baselines, I was surprised to see changes to the baseline for an existing test, tests/baselines/reference/narrowingDestructuring.types
:
function farr<T extends [number, string, string] | [string, number, number]>(
x: T
) {
const [head, ...tail] = x;
if (x[0] === "number") {
const [head, ...tail] = x;
}
}
const [head, ...tail] = x;
>head : string | number
- >tail : (string | number)[]
+ >tail : [string, string] | [number, number]
The updated baseline is more correct!
The type of tail
(elements in x
after head
) indeed is [string, string] | [number, number]
.
My change improved an existing test baseline!
Yay!
🥳
…and with tests working, I was able to send a pull request. Fixed tuple types indexed by type parameter. ✨
Improving a Test
@Andarist commented on GitHub that the test probably meant to check typeof x[0] === "number"
, not just x[0] === "number"
.
I ended up filing #52410 narrowingDestructuring test missing a ‘typeof’ operator in writing this blog post.
Final Thanks
Thanks to @sandersn for reviewing and merging the PR from the TypeScript team’s side. Additional thanks to @Zamiell for reporting the issue in the first place, and @Andarist for posting helpful comments on the resultant pull request. Cheers! 🙌