Skip to content
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

Generic constraint wipes out precise key type #56912

Open
xixixao opened this issue Dec 31, 2023 · 3 comments Β· May be fixed by #56953
Open

Generic constraint wipes out precise key type #56912

xixixao opened this issue Dec 31, 2023 · 3 comments Β· May be fixed by #56953
Labels
Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases
Milestone

Comments

@xixixao
Copy link

xixixao commented Dec 31, 2023

πŸ”Ž 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).

@Andarist
Copy link
Contributor

You can fix this by using NoInfer in your return type: TS playground

This prevents the contextual type created by the outer call from being used as an inference source for the inferred type of the inner call.

@RyanCavanaugh
Copy link
Member

The discrepancy between addNameThatWorksFine and addName feels off to me - these are different-priority inferences to be sure, but both should be higher priority than the return type.

@RyanCavanaugh RyanCavanaugh added Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases labels Jan 2, 2024
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Jan 2, 2024
@Andarist
Copy link
Contributor

Andarist commented Jan 3, 2024

This is kind of an inverse of #52864 . That other issue is about the argument position not receiving the contextual information from the outer context. The same happens here (with addNameThatWorksFine), it's just that for that other issue that is the problem, and here it "fixes" the problem since the other case is broken here.

The problem is that even though the return type has a lower inference priority, those inferences are still incorporated into contextual typing of the inner arguments through returnMapper.

  1. checkExpressionForMutableLocation calls getWidenedLiteralLikeTypeForContextualType(type, instantiateContextualType(getContextualType(node, /*contextFlags*/ undefined), node, /*contextFlags*/ undefined))
  2. the contextualType passed to instantiateContextualType is Name but it gets instantiated to just never based on the returnMapper. This candidate was created based on inferToMappedType and when inferring with InferencePriority.MappedTypeConstraint. This never was inferred from keyof {} and that tainted the contextual type instantiation
  3. that widens this mutable location and now the inferTypes that follows checkExpressionWithContextualType can't infer better information (the inference source became string at the previous step)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants