Josh Goldberg
Black cat walking on a holiday light decorated railing towards the viewer, tail held high, looking at the viewer

TypeScript Contribution Diary: Tuple Types Indexed by a Type Parameter

Mar 30, 202310 minute read

Fixing a slight bug in how TypeScript resolves type elements of tuple types indexed by type parameters.

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:

…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.
  1. isDeclarationNameOrImportPropertyName evaluates to true, so TypeScript calls to…
  2. getTypeOfSymbol: symbol.flags & (SymbolFlags.Variable | SymbolFlags.Property) is true, so getTypeOfVariableOrParameterOrProperty is called, which calls to…
  3. getTypeOfVariableOrParameterOrPropertyWorker: ts.isBindingElement(declaration) is true, so TypeScript calls to…
  4. getWidenedTypeForVariableLikeDeclaration: which calls to…
  5. getTypeForVariableLikeDeclaration: isBindingPattern(declaration.parent) is true, so TypeScript calls to…
  6. getTypeForBindingElement: checkMode is CheckMode.RestBindingElement and parentType does exist.
    • Calling typeToString(parentType) produces '["a"][][N]'.
    • Because parentType exists, TypeScript calls to…
  7. 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! 🙌