Skip to content

Type tuples no longer inferred properly from rest arguments with mapped generic type tuples. #49556

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
joelek opened this issue Jun 15, 2022 · 9 comments · Fixed by #52848
Closed
Assignees
Labels
Fix Available A PR has been opened for this issue Has Repro This issue has compiler-backed repros: https://aka.ms/ts-repros Needs Investigation This issue needs a team member to investigate its status. Rescheduled This issue was previously scheduled to an earlier milestone

Comments

@joelek
Copy link

joelek commented Jun 15, 2022

I recently updated the dependencies for a TypeScript-based project and unfortunately ended up with some unexpected breakage.

The TypeScript version was changed from 4.6 to 4.7 and type tuples are no longer inferred properly from rest arguments with mapped generic type tuples.

type MyMappedType<Primitive extends any> = {
	primitive: Primitive;
};

The project uses several helper types to map type tuples to other type tuples as shown below.

type TupleMapperOld<Tuple extends any[]> = {
	[Key in keyof Tuple]: Tuple[Key] extends Tuple[number] ? MyMappedType<Tuple[Key]> : never;
};

After updating to TypeScript 4.7 it is still possible to use the helper directly. TypeScript will correctly infer the type as expected.

// Type should be [MyMappedType<string>, MyMappedType<number>] in TypeScript 4.6 and TypeScript 4.7.
type MyMappedTupleOld = TupleMapperOld<[string, number]>;

However, type inference breaks when using the mapper to map the rest arguments of a generic function as shown below.

function extractPrimitivesOld<Tuple extends any[]>(...mappedTypes: TupleMapperOld<Tuple>): Tuple {
	return mappedTypes.map((mappedType) => mappedType.primitive) as Tuple;
}

// Inferred type should be [string, number] but becomes [unknown, unknown] in TypeScript 4.7.
const myPrimitiveTupleOld = extractPrimitivesOld({ primitive: "" }, { primitive: 0 });

The problem can be mitigated by explicitly specifying the generic type tuple argument. It can also be solved by removing the mapping constraints in the mapper as shown below.

type TupleMapperNew<Tuple extends any[]> = {
	[Key in keyof Tuple]: MyMappedType<Tuple[Key]>;
};

I decided to open this issue and let you decide if this is something worth investigating since I couldn't find any information about the difference in behaviour in the release notes.

🔎 Search Terms

tuple mapping
type inference
rest arguments
generics

🕗 Version & Regression Information

This changed between versions 4.6 and 4.7.

⏯ Playground Link

Playground 4.6.4

Playground 4.7.2

💻 Code

type MyMappedType<Primitive extends any> = {
	primitive: Primitive;
};

type TupleMapperOld<Tuple extends any[]> = {
	[Key in keyof Tuple]: Tuple[Key] extends Tuple[number] ? MyMappedType<Tuple[Key]> : never;
};

// Type should be [MyMappedType<string>, MyMappedType<number>] in TypeScript 4.6 and TypeScript 4.7.
type MyMappedTupleOld = TupleMapperOld<[string, number]>;

function extractPrimitivesOld<Tuple extends any[]>(...mappedTypes: TupleMapperOld<Tuple>): Tuple {
	return mappedTypes.map((mappedType) => mappedType.primitive) as Tuple;
}

// Inferred type should be [string, number] but becomes [unknown, unknown] in TypeScript 4.7.
const myPrimitiveTupleOld = extractPrimitivesOld({ primitive: "" }, { primitive: 0 });
//    ^?

type TupleMapperNew<Tuple extends any[]> = {
	[Key in keyof Tuple]: MyMappedType<Tuple[Key]>;
};

// Type should be [MyMappedType<string>, MyMappedType<number>] in TypeScript 4.6 and TypeScript 4.7.
type MyMappedTupleNew = TupleMapperNew<[string, number]>;

function extractPrimitivesNew<Tuple extends any[]>(...mappedTypes: TupleMapperNew<Tuple>): Tuple {
	return mappedTypes.map((mappedType) => mappedType.primitive) as Tuple;
}

// Inferred type should be [string, number] in TypeScript 4.6 and TypeScript 4.7.
const myPrimitiveTupleNew = extractPrimitivesNew({ primitive: "" }, { primitive: 0 });

🙁 Actual behavior

The type tuple is inferred as [unknown, unknown] in TypeScript 4.7.

🙂 Expected behavior

The type tuple is inferred as [string, number] in TypeScript 4.7.

@RyanCavanaugh RyanCavanaugh added the Needs Investigation This issue needs a team member to investigate its status. label Jun 15, 2022
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 4.8.1 milestone Jun 15, 2022
@typescript-bot typescript-bot added the Has Repro This issue has compiler-backed repros: https://aka.ms/ts-repros label Jul 6, 2022
@andrewbranch
Copy link
Member

@typescript-bot bisect good v4.6.4 bad v4.7.4

@typescript-bot
Copy link
Collaborator

The change between v4.6.4 and v4.7.4 occurred at 787bb9d.

@andrewbranch
Copy link
Member

type TupleMapperOld<Tuple extends any[]> = {
  [Key in keyof Tuple]: Tuple[Key] extends Tuple[number] ? MyMappedType<Tuple[Key]> : never;
};

What was the purpose of this conditional type? At a glance it seems like it should always evaluate true.

@typescript-bot
Copy link
Collaborator

typescript-bot commented Jul 7, 2022

👋 Hi, I'm the Repro bot. I can help narrow down and track compiler bugs across releases! This comment reflects the current state of the repro in the issue body running against the nightly TypeScript.


Issue body code block by @joelek

⚠️ Assertions:

  • const myPrimitiveTupleOld: [unknown, unknown]

Historical Information
Version Reproduction Outputs
4.7.2

⚠️ Assertions:

  • const myPrimitiveTupleOld: [unknown, unknown]

4.3.2, 4.4.2, 4.5.2, 4.6.2

⚠️ Assertions:

  • const myPrimitiveTupleOld: [string, number]

@jakebailey
Copy link
Member

jakebailey commented Jul 7, 2022

So, at first glance, the above appears to be intended to filter the keys of a tuple type to only the numeric indexes (so, filter out any named things like if you had any[] & { foo: number }, except that it seems to not really work correctly because if you have [string, number] & { foo: number }, foo wouldn't be filtered out because it has a value type of number (Tuple['foo']) and that is one of the values of the indexed types (Tuple[number] is string | number). It would seem like the right thing to write would be:

type TupleMapper2<Tuple extends any[]> = {
  [Key in keyof Tuple]: Key extends number ? MyMappedType<Tuple[Key]> : never;
};

This doesn't doesn't compile and has a confusing error at the call.

But, depending on what we think keyof Tuple is supposed to be comprised of, this would feel like it should always be true (and that is in fact what the "New" variants in the OP are doing).

#48837 seems to be intended to change indexed accesses to arrays/tuples to not only be number but also `${number}`. So, if you change the above such that it is still always true (presumably, #48837 was a breaking change in that way), you can write:

type TupleMapper3<Tuple extends any[]> = {
  [Key in keyof Tuple]: Key extends (number | `${number}`) ? MyMappedType<Tuple[Key]> : never;
};

And this compiles with the original example in 4.7, so maybe it's a workaround?

If we go back to the original example, it would seem like we could make the same transformation by adding `${number}` and write:

type TupleMapper4<Tuple extends any[]> = {
	[Key in keyof Tuple]: Tuple[Key] extends (Tuple[number] | Tuple[`${number}`]) ? MyMappedType<Tuple[Key]> : never;
};

Except that we get an error that says that Tuple cannot be indexed by `${number}`, which is what #48837 seems to have been intended to change, so that feels incongruent (as though there's some other part of the checker that also needs to be updated to allow this). I'm not sure if that's the entirety of the problem or if it's that + the fact that #48837 sort of broke the assumptions that code can make about what keys can be in arrays/tuples.

All examples better laid out here: Playground Link

@andrewbranch
Copy link
Member

I think the upshot of this is that the purpose of the conditional type was to do exactly what #48837 does unconditionally, so it can just be removed. The conditional type is a workaround for the previous weird behavior of mapping over generic tuples/arrays. That said, I think the lost inference in the original example is still probably a bug.

@jakebailey
Copy link
Member

Just to complete my side investigation, the "Type '`${number}`' cannot be used to index type 'Tuple'." message is caused by our definition of the index type for Array, which is defined as [n: number]: T; changing this to n: number | `${number}`]: T makes the error go away, but does not fix the inferencing problem reported in this issue, so I doubt it is the cause like you say. (It also breaks a number of other things in mysterious ways, so I doubt it is a desirable change anyhow, at least not the prototype I tested.)

@joelek
Copy link
Author

joelek commented Jul 8, 2022

I think the upshot of this is that the purpose of the conditional type was to do exactly what #48837 does unconditionally, so it can just be removed. The conditional type is a workaround for the previous weird behavior of mapping over generic tuples/arrays. That said, I think the lost inference in the original example is still probably a bug.

Helpers like the conditionally mapped type TupleMapperOld is what you find when you search the web for type tuple mapping in TypeScript even though it no longer seems to be needed.

I started using the pattern a few years ago due to having issues with unwanted mapping over non-numeric keys and I believe that the pattern is somewhat well-established in code bases. The fix is simple, just remove the conditional, but the weird behavior of lost inference in certain circumstances might leave people puzzled and could, as you say, indicate a bug.

@RyanCavanaugh RyanCavanaugh added the Rescheduled This issue was previously scheduled to an earlier milestone label Feb 1, 2023
@typescript-bot typescript-bot added Fix Available A PR has been opened for this issue labels Feb 19, 2023
@jakebailey
Copy link
Member

This seems to have turned out to just be a bug; see #52848.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Fix Available A PR has been opened for this issue Has Repro This issue has compiler-backed repros: https://aka.ms/ts-repros Needs Investigation This issue needs a team member to investigate its status. Rescheduled This issue was previously scheduled to an earlier milestone
Projects
None yet
5 participants