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 singletonsis 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 singletonshould be["a"]: what you’d get by accessing any elementi([N]) undersingletons’s type (["a"][])
- The type of restis 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.
- isDeclarationNameOrImportPropertyNameevaluates to- true, so TypeScript calls to…
- getTypeOfSymbol:- symbol.flags & (SymbolFlags.Variable | SymbolFlags.Property)is true, so- getTypeOfVariableOrParameterOrPropertyis called, which calls to…
- getTypeOfVariableOrParameterOrPropertyWorker:- ts.isBindingElement(declaration)is true, so TypeScript calls to…
- getWidenedTypeForVariableLikeDeclaration: which calls to…
- getTypeForVariableLikeDeclaration:- isBindingPattern(declaration.parent)is- true, so TypeScript calls to…
- getTypeForBindingElement:- checkModeis- CheckMode.RestBindingElementand- parentTypedoes exist.- Calling typeToString(parentType)produces'["a"][][N]'.
- Because parentTypeexists, 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! 🙌
