Microsoft/TypeScript
Auf GitHub ansehenGeneric constraint wipes out precise key type
Open
#56.912 geöffnet am 31. Dez. 2023
Domain: Mapped TypesHelp WantedPossible Improvement
Repository-Metriken
- Stars
- (48.455 Stars)
- PR-Merge-Metriken
- (Durchschn. Merge 6T 17h) (9 gemergte PRs in 30 T)
Beschreibung
🔎 Search Terms
generic, constraint, key in, key, mapped types
🕗 Version & Regression Information
- This is the behavior in every version I tried, and I reviewed the FAQ for entries about Mapped types
⏯ Playground Link
💻 Code
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/ban-types */
// This is a minimal repro, boiled down from a much, much more complicated
// project where all these types are required and make sense.
// First what we're after, this NameBag we're trying to construct:
interface NameBag<Names extends Record<string, any> = {}> {
// This is the method we're trying to fix:
addName<Name extends string>(options: {
name: Name;
}): NameBag<
Names & {
[key in Name]: { name: true };
}
>;
// We'll come back to this method in Part 2
addNameThatWorksFine<Name extends string>(
name: Name
): NameBag<
Names & {
[key in Name]: { name: true };
}
>;
}
/// Part 1: Demonstrating the issue
// Helper to get the generic type out of a NameBag type:
export type ExtractNamesType<T> = T extends NameBag<infer E> ? E : never;
// Lets pretend we have an empty NameBag
const emptyBag: NameBag = null as any;
// And add a name to it
const filledBag = emptyBag.addName({ name: "hey!" });
// Let's get the type of the filled bag's names:
type Names1 = ExtractNamesType<typeof filledBag>;
// So far, no issue, we get what we expect:
assert<Equals<"hey!", keyof Names1>>;
// To trigger the issue, we need another wrapper. The `extends`
// constraint here is what breaks us:
function wrapper<Schema extends Record<string, NameBag>>(
schema: Schema
): Schema {
return schema;
}
// The issue only occurs if we create filledBag inline:
const bagOfBags = wrapper({
// this is the same code as we used for filledBag above:
bad: emptyBag.addName({ name: "hey!" }),
good: filledBag,
});
// And here you can see that the `bad` bag loses its precise type:
type TBad = ExtractNamesType<(typeof bagOfBags)["bad"]>;
assert<Equals<"hey!", keyof TBad>>;
// But super surprisingly, the `good` bag is fine:
type TGood = ExtractNamesType<(typeof bagOfBags)["good"]>;
assert<Equals<"hey!", keyof TGood>>;
/// Part 2: What else works
// Assigning the NameBag to a variable works:
let fixTypeChecking;
const bagOfBags2 = wrapper({
// this is the same code as we used for filledBag above:
works: (fixTypeChecking = emptyBag.addName({ name: "hey!" })),
});
type TWorks2 = ExtractNamesType<(typeof bagOfBags2)["works"]>;
assert<Equals<"hey!", keyof TWorks2>>;
// Using an argument to `addName` as opposed to an object with a field
// works:
const bagOfBags3 = wrapper({
// this is the same code as we used for filledBag above:
works: emptyBag.addNameThatWorksFine("hey!"),
});
type TWorks3 = ExtractNamesType<(typeof bagOfBags2)["works"]>;
assert<Equals<"hey!", keyof TWorks3>>;
// Changing the generic in the constraint to NameBag works!!!:
function wrapper2<Schema extends Record<string, NameBag<any>>>(
schema: Schema
): Schema {
return schema;
}
const bagOfBags4 = wrapper2({
// this is the same code as we used for filledBag above:
bad: emptyBag.addName({ name: "hey!" }),
});
type TBad4 = ExtractNamesType<(typeof bagOfBags4)["bad"]>;
assert<Equals<"hey!", keyof TBad4>>;
🙁 Actual behavior
You can see that in certain scenarios the exact key type of the mapped type degrades back to string.
🙂 Expected behavior
The exact key type should always be preserved.
Additional information about the issue
The last working version is a fine workaround that I ended up using (edit: it broke things down the line, see @Andarist's proper fix until a fix comes out in the compiler), but I though I'd share the issue anyway, as the "assign to variable and get a different type" is extremely surprising for me (I also tried to find this in FAQ).