microsoft / microsoft/TypeScript

Generic constraint wipes out precise key type

Open
#56,912 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Domain: Mapped Types Help Wanted Possible Improvement
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

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.