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
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
evalutates 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! 🙌