Microsoft/TypeScript

Generic constraint wipes out precise key type

Open

#56.912 aberto em 31 de dez. de 2023

Ver no GitHub
 (3 comments) (0 reactions) (0 assignees)TypeScript (6.726 forks)batch import
Domain: Mapped TypesHelp WantedPossible Improvement

Métricas do repositório

Stars
 (48.455 stars)
Métricas de merge de PR
 (Mesclagem média 6d 17h) (9 fundiu PRs em 30d)

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).

Guia do colaborador