TypeScript Contribution Diary: Tuple Types Indexed by a Type Parameter
March 30, 2023
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 knarly 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.
-
isDeclarationNameOrImportPropertyNameevalutates totrue, so TypeScript calls to… -
getTypeOfSymbol:symbol.flags & (SymbolFlags.Variable | SymbolFlags.Property)is true, sogetTypeOfVariableOrParameterOrPropertyis 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:checkModeisCheckMode.RestBindingElementandparentTypedoes 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! 🙌