microsoft / microsoft/TypeScript
Generic constraint wipes out precise key type
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 111k
- Forks
- 14.4k
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 117
Description
🔎 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).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the linked TypeScript Playground reproduction, especially the wrapper constraint and the inline addName call that produces TBad. Trace how the compiler handles generic constraints and mapped types; done means preserving the exact "hey!" key in TBad while keeping the existing assertions valid.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100