Skip to content

Treat array literal contextually typed by homomorphic mapped types as in tuple context #56555

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

Merged
merged 3 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/compiler/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31018,7 +31018,8 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker {
const inDestructuringPattern = isAssignmentTarget(node);
const inConstContext = isConstContext(node);
const contextualType = getApparentTypeOfContextualType(node, /*contextFlags*/ undefined);
const inTupleContext = isSpreadIntoCallOrNew(node) || !!contextualType && someType(contextualType, isTupleLikeType);
const inTupleContext = isSpreadIntoCallOrNew(node) || !!contextualType && someType(contextualType, t => isTupleLikeType(t) || isGenericMappedType(t) && !t.nameType && !!getHomomorphicTypeVariable(t.target as MappedType || t));

let hasOmittedExpression = false;
for (let i = 0; i < elementCount; i++) {
const e = elements[i];
Expand Down
148 changes: 148 additions & 0 deletions tests/baselines/reference/reverseMappedTupleContext.symbols
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//// [tests/cases/compiler/reverseMappedTupleContext.ts] ////

=== reverseMappedTupleContext.ts ===
// https://github.com/microsoft/TypeScript/issues/55382

declare function test1<T>(arg: {
>test1 : Symbol(test1, Decl(reverseMappedTupleContext.ts, 0, 0))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 2, 23))
>arg : Symbol(arg, Decl(reverseMappedTupleContext.ts, 2, 26))

[K in keyof T]: T[K];
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 3, 3))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 2, 23))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 2, 23))
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 3, 3))

}): T;
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 2, 23))

const result1 = test1(["foo", 42]);
>result1 : Symbol(result1, Decl(reverseMappedTupleContext.ts, 5, 5))
>test1 : Symbol(test1, Decl(reverseMappedTupleContext.ts, 0, 0))

declare function test2<T extends readonly unknown[]>(arg: {
>test2 : Symbol(test2, Decl(reverseMappedTupleContext.ts, 5, 35))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 7, 23))
>arg : Symbol(arg, Decl(reverseMappedTupleContext.ts, 7, 53))

[K in keyof T]: T[K];
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 8, 3))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 7, 23))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 7, 23))
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 8, 3))

}): T;
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 7, 23))

const result2 = test2(["foo", 42]);
>result2 : Symbol(result2, Decl(reverseMappedTupleContext.ts, 10, 5))
>test2 : Symbol(test2, Decl(reverseMappedTupleContext.ts, 5, 35))

type Schema = Record<string, unknown> | readonly unknown[];
>Schema : Symbol(Schema, Decl(reverseMappedTupleContext.ts, 10, 35))
>Record : Symbol(Record, Decl(lib.es5.d.ts, --, --))

type Definition<T> = {
>Definition : Symbol(Definition, Decl(reverseMappedTupleContext.ts, 12, 59))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 13, 16))

[K in keyof T]: (() => T[K]) | Definition<T[K]>;
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 14, 3))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 13, 16))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 13, 16))
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 14, 3))
>Definition : Symbol(Definition, Decl(reverseMappedTupleContext.ts, 12, 59))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 13, 16))
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 14, 3))

};
declare function create<T extends Schema>(definition: Definition<T>): T;
>create : Symbol(create, Decl(reverseMappedTupleContext.ts, 15, 2))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 16, 24))
>Schema : Symbol(Schema, Decl(reverseMappedTupleContext.ts, 10, 35))
>definition : Symbol(definition, Decl(reverseMappedTupleContext.ts, 16, 42))
>Definition : Symbol(Definition, Decl(reverseMappedTupleContext.ts, 12, 59))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 16, 24))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 16, 24))

const created1 = create([() => 1, [() => ""]]);
>created1 : Symbol(created1, Decl(reverseMappedTupleContext.ts, 17, 5))
>create : Symbol(create, Decl(reverseMappedTupleContext.ts, 15, 2))

const created2 = create({
>created2 : Symbol(created2, Decl(reverseMappedTupleContext.ts, 18, 5))
>create : Symbol(create, Decl(reverseMappedTupleContext.ts, 15, 2))

a: () => 1,
>a : Symbol(a, Decl(reverseMappedTupleContext.ts, 18, 25))

b: [() => ""],
>b : Symbol(b, Decl(reverseMappedTupleContext.ts, 19, 13))

});

interface CompilerOptions {
>CompilerOptions : Symbol(CompilerOptions, Decl(reverseMappedTupleContext.ts, 21, 3))

allowUnreachableCode?: boolean;
>allowUnreachableCode : Symbol(CompilerOptions.allowUnreachableCode, Decl(reverseMappedTupleContext.ts, 23, 27))

allowUnusedLabels?: boolean;
>allowUnusedLabels : Symbol(CompilerOptions.allowUnusedLabels, Decl(reverseMappedTupleContext.ts, 24, 33))

alwaysStrict?: boolean;
>alwaysStrict : Symbol(CompilerOptions.alwaysStrict, Decl(reverseMappedTupleContext.ts, 25, 30))
}
type KeepLiteralStrings<T extends string[]> = {
>KeepLiteralStrings : Symbol(KeepLiteralStrings, Decl(reverseMappedTupleContext.ts, 27, 1))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 28, 24))

[K in keyof T]: T[K];
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 29, 3))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 28, 24))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 28, 24))
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 29, 3))

};
declare function test4<T extends Record<string, string[]>>(obj: {
>test4 : Symbol(test4, Decl(reverseMappedTupleContext.ts, 30, 2))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 31, 23))
>Record : Symbol(Record, Decl(lib.es5.d.ts, --, --))
>obj : Symbol(obj, Decl(reverseMappedTupleContext.ts, 31, 59))

[K in keyof T & keyof CompilerOptions]: {
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 32, 3))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 31, 23))
>CompilerOptions : Symbol(CompilerOptions, Decl(reverseMappedTupleContext.ts, 21, 3))

dependencies: KeepLiteralStrings<T[K]>;
>dependencies : Symbol(dependencies, Decl(reverseMappedTupleContext.ts, 32, 43))
>KeepLiteralStrings : Symbol(KeepLiteralStrings, Decl(reverseMappedTupleContext.ts, 27, 1))
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 31, 23))
>K : Symbol(K, Decl(reverseMappedTupleContext.ts, 32, 3))

};
}): T;
>T : Symbol(T, Decl(reverseMappedTupleContext.ts, 31, 23))

const result4 = test4({
>result4 : Symbol(result4, Decl(reverseMappedTupleContext.ts, 36, 5))
>test4 : Symbol(test4, Decl(reverseMappedTupleContext.ts, 30, 2))

alwaysStrict: {
>alwaysStrict : Symbol(alwaysStrict, Decl(reverseMappedTupleContext.ts, 36, 23))

dependencies: ["foo", "bar"],
>dependencies : Symbol(dependencies, Decl(reverseMappedTupleContext.ts, 37, 17))

},
allowUnusedLabels: {
>allowUnusedLabels : Symbol(allowUnusedLabels, Decl(reverseMappedTupleContext.ts, 39, 4))

dependencies: ["baz", "qwe"],
>dependencies : Symbol(dependencies, Decl(reverseMappedTupleContext.ts, 40, 22))

},
});

130 changes: 130 additions & 0 deletions tests/baselines/reference/reverseMappedTupleContext.types
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
//// [tests/cases/compiler/reverseMappedTupleContext.ts] ////

=== reverseMappedTupleContext.ts ===
// https://github.com/microsoft/TypeScript/issues/55382

declare function test1<T>(arg: {
>test1 : <T>(arg: { [K in keyof T]: T[K]; }) => T
>arg : { [K in keyof T]: T[K]; }

[K in keyof T]: T[K];
}): T;
const result1 = test1(["foo", 42]);
>result1 : [string, number]
>test1(["foo", 42]) : [string, number]
>test1 : <T>(arg: { [K in keyof T]: T[K]; }) => T
>["foo", 42] : [string, number]
>"foo" : "foo"
>42 : 42

declare function test2<T extends readonly unknown[]>(arg: {
>test2 : <T extends readonly unknown[]>(arg: { [K in keyof T]: T[K]; }) => T
>arg : { [K in keyof T]: T[K]; }

[K in keyof T]: T[K];
}): T;
const result2 = test2(["foo", 42]);
>result2 : [string, number]
>test2(["foo", 42]) : [string, number]
>test2 : <T extends readonly unknown[]>(arg: { [K in keyof T]: T[K]; }) => T
>["foo", 42] : [string, number]
>"foo" : "foo"
>42 : 42

type Schema = Record<string, unknown> | readonly unknown[];
>Schema : readonly unknown[] | Record<string, unknown>

type Definition<T> = {
>Definition : Definition<T>

[K in keyof T]: (() => T[K]) | Definition<T[K]>;
};
declare function create<T extends Schema>(definition: Definition<T>): T;
>create : <T extends Schema>(definition: Definition<T>) => T
>definition : Definition<T>

const created1 = create([() => 1, [() => ""]]);
>created1 : [number, [string]]
>create([() => 1, [() => ""]]) : [number, [string]]
>create : <T extends Schema>(definition: Definition<T>) => T
>[() => 1, [() => ""]] : [() => number, [() => string]]
>() => 1 : () => number
>1 : 1
>[() => ""] : [() => string]
>() => "" : () => string
>"" : ""

const created2 = create({
>created2 : { a: number; b: [string]; }
>create({ a: () => 1, b: [() => ""],}) : { a: number; b: [string]; }
>create : <T extends Schema>(definition: Definition<T>) => T
>{ a: () => 1, b: [() => ""],} : { a: () => number; b: [() => string]; }

a: () => 1,
>a : () => number
>() => 1 : () => number
>1 : 1

b: [() => ""],
>b : [() => string]
>[() => ""] : [() => string]
>() => "" : () => string
>"" : ""

});

interface CompilerOptions {
allowUnreachableCode?: boolean;
>allowUnreachableCode : boolean | undefined

allowUnusedLabels?: boolean;
>allowUnusedLabels : boolean | undefined

alwaysStrict?: boolean;
>alwaysStrict : boolean | undefined
}
type KeepLiteralStrings<T extends string[]> = {
>KeepLiteralStrings : KeepLiteralStrings<T>

[K in keyof T]: T[K];
};
declare function test4<T extends Record<string, string[]>>(obj: {
>test4 : <T extends Record<string, string[]>>(obj: { [K in keyof T & keyof CompilerOptions]: { dependencies: KeepLiteralStrings<T[K]>; }; }) => T
>obj : { [K in keyof T & keyof CompilerOptions]: { dependencies: KeepLiteralStrings<T[K]>; }; }

[K in keyof T & keyof CompilerOptions]: {
dependencies: KeepLiteralStrings<T[K]>;
>dependencies : KeepLiteralStrings<T[K]>

};
}): T;
const result4 = test4({
>result4 : { alwaysStrict: ["foo", "bar"]; allowUnusedLabels: ["baz", "qwe"]; }
>test4({ alwaysStrict: { dependencies: ["foo", "bar"], }, allowUnusedLabels: { dependencies: ["baz", "qwe"], },}) : { alwaysStrict: ["foo", "bar"]; allowUnusedLabels: ["baz", "qwe"]; }
>test4 : <T extends Record<string, string[]>>(obj: { [K in keyof T & keyof CompilerOptions]: { dependencies: KeepLiteralStrings<T[K]>; }; }) => T
>{ alwaysStrict: { dependencies: ["foo", "bar"], }, allowUnusedLabels: { dependencies: ["baz", "qwe"], },} : { alwaysStrict: { dependencies: ["foo", "bar"]; }; allowUnusedLabels: { dependencies: ["baz", "qwe"]; }; }

alwaysStrict: {
>alwaysStrict : { dependencies: ["foo", "bar"]; }
>{ dependencies: ["foo", "bar"], } : { dependencies: ["foo", "bar"]; }

dependencies: ["foo", "bar"],
>dependencies : ["foo", "bar"]
>["foo", "bar"] : ["foo", "bar"]
>"foo" : "foo"
>"bar" : "bar"

},
allowUnusedLabels: {
>allowUnusedLabels : { dependencies: ["baz", "qwe"]; }
>{ dependencies: ["baz", "qwe"], } : { dependencies: ["baz", "qwe"]; }

dependencies: ["baz", "qwe"],
>dependencies : ["baz", "qwe"]
>["baz", "qwe"] : ["baz", "qwe"]
>"baz" : "baz"
>"qwe" : "qwe"

},
});

Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const myUnion = unionType([identifierExtractor, stringExtractor]);
>myUnion : AnyExtractor<{ node: Identifier; kind: "identifier"; value: string; } | { node: StringLiteral; kind: "string"; value: string; }>
>unionType([identifierExtractor, stringExtractor]) : AnyExtractor<{ node: Identifier; kind: "identifier"; value: string; } | { node: StringLiteral; kind: "string"; value: string; }>
>unionType : <Result extends readonly unknown[]>(parsers: { [K in keyof Result]: AnyExtractor<Result[K]>; }) => AnyExtractor<Result[number]>
>[identifierExtractor, stringExtractor] : (Extractor<Identifier, { node: Identifier; kind: "identifier"; value: string; }> | Extractor<StringLiteral, { node: StringLiteral; kind: "string"; value: string; }>)[]
>[identifierExtractor, stringExtractor] : [Extractor<Identifier, { node: Identifier; kind: "identifier"; value: string; }>, Extractor<StringLiteral, { node: StringLiteral; kind: "string"; value: string; }>]
>identifierExtractor : Extractor<Identifier, { node: Identifier; kind: "identifier"; value: string; }>
>stringExtractor : Extractor<StringLiteral, { node: StringLiteral; kind: "string"; value: string; }>

47 changes: 47 additions & 0 deletions tests/cases/compiler/reverseMappedTupleContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @strict: true
// @noEmit: true

// https://github.com/microsoft/TypeScript/issues/55382

declare function test1<T>(arg: {
[K in keyof T]: T[K];
}): T;
const result1 = test1(["foo", 42]);

declare function test2<T extends readonly unknown[]>(arg: {
[K in keyof T]: T[K];
}): T;
const result2 = test2(["foo", 42]);
Comment on lines +11 to +14
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO this alone is worth this improvement. Today we can infer tuples using mapped types but we need to awkwardly spread them (TS playground):

declare function broken<T extends readonly unknown[]>(arg: {
  [K in keyof T]: T[K];
}): T;
const result1 = broken(["foo", 42]);
//    ^? const result1: (string | number)[]

declare function workaround<T extends readonly unknown[]>(
  arg: [
    ...{
      [K in keyof T]: T[K];
    },
  ],
): T;
const result2 = workaround(["foo", 42]);
//    ^? const result2: [string, number]

That makes the recursive scenarios much more complicated because we can't "just" iterate over the recursive type parameter and have that tuple context assigned to array literals.

IMHO, the mapped type itself strongly indicates that the user wants to have the tuples inferred as they might want to type each element separately.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn’t infer the tuple type if T is marked const? I thought that was the whole reason behind const type params, because people wanted to infer tuples instead of (T | U | V)[]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn’t infer the tuple type if T is marked const?

It does (although not always, see this TS playground, but that is being fixed by #55794

I thought that was the whole reason behind const type params, because people wanted to infer tuples instead of (T | U | V)[]

I don't think that was the whole reason behind it. The reason was that people want to use schema-like arguments for which often strings, numbers, etc have to be kept as literal strings. The fact that tuples are also preserved is just a byproduct of this - since a tuple is a more specific type than an array, just like a string literal is a more specific type than a string type.

Regardless... there are times when constness of the whole argument is not something that I want and preserving the "tuple context" is all that I want. I might want to infer [string] and not let's say ["foo"].

Copy link

@fatcerberus fatcerberus Nov 27, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might want to infer [string] and not let's say ["foo"].

Hmm, now that I think about this, there’s no precedent for that in the type system today, is there? When inferring arrays in situ you either write [ "foo", "bar" ] and get string[] or else add an as const and get readonly [ "foo", "bar" ].

So the upshot is that this change would add a way to infer [ string, string ] from an array literal via a generic and people will start writing more constrained identity functions to accomplish that (…and likely complain about having to do so 😜)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can already do this today - you have to use the workaround mentioned above. This PR makes this whole thing simpler while also making it possible (or just way easier) to write recursive types that preserve tuples like this (especially in the "mixed" scenarios when your type is supposed to iterate through both object and array types).

I also think that the workaround above isn't that intuitive. After all an unconstrained generic mapped type when iterating over a tuple preserves its tupleness.I would also conceptually consider all array literal expressions to start as tuples~. It's the widening behavior that usually widens them to arrays unless some other hint that the tuple should be preserved is given. So I just expect that contextual mapped type to be considered as such a hint.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s fair. To be honest it just feels wrong that generic inference seems to have more knobs than what you can control inline. In general, the fewer CIFs I have to write to get type inference to do what I want, the better.


type Schema = Record<string, unknown> | readonly unknown[];
type Definition<T> = {
[K in keyof T]: (() => T[K]) | Definition<T[K]>;
};
declare function create<T extends Schema>(definition: Definition<T>): T;
const created1 = create([() => 1, [() => ""]]);
const created2 = create({
a: () => 1,
b: [() => ""],
});

interface CompilerOptions {
allowUnreachableCode?: boolean;
allowUnusedLabels?: boolean;
alwaysStrict?: boolean;
}
type KeepLiteralStrings<T extends string[]> = {
[K in keyof T]: T[K];
};
declare function test4<T extends Record<string, string[]>>(obj: {
[K in keyof T & keyof CompilerOptions]: {
dependencies: KeepLiteralStrings<T[K]>;
};
}): T;
const result4 = test4({
alwaysStrict: {
dependencies: ["foo", "bar"],
},
allowUnusedLabels: {
dependencies: ["baz", "qwe"],
},
});