TypeScript icon indicating copy to clipboard operation
TypeScript copied to clipboard

Generic param that extends mapped type behaves differently when cast to the same type

Open davidnx opened this issue 3 years ago • 3 comments

Bug Report

In a generic function, a param that extends a mapped type doesn't properly support the type it extends. Casting such param explicitly to the same type that it already extends, surprisingly, makes it properly support the type it extends.

🔎 Search Terms

  • ts2322, 2322
  • Mapped type extends cast

🕗 Version & Regression Information

Verified on 4.8.4, 4.9.3, and 5.0.0-dev.20221127

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about keyof and found nothing related to what I am observing

⏯ Playground Link

Playground link with relevant code

💻 Code

type RequireStringField<T, TKey extends keyof T> = {
  [P in TKey]: string;
};

function myFunc<T extends RequireStringField<T, TKey>, TKey extends keyof T>(
  obj: T,
  field: TKey
) {
  // This should work, but doesn't (tested on Typescript 4.8.4):
  obj[field] = "modified"; // ❌: Type 'string' is not assignable to type 'T[TKey]'. (ts2322)

  // This works, even though the cast would seem like a superfluous no-op...
  // (we are casting `obj` to the type it already extends):
  (obj as RequireStringField<T, TKey>)[field] = "modified"; // ✅: works
}

myFunc({field1: "a", field2: 1}, "field1"); // ✅: works

davidnx avatar Nov 28 '22 04:11 davidnx

The error is correct. The only guarantee made for T is that it's assignable to RequireStringField<T, TKey>, which means the properties involved might be narrower than string:

Playground link

type Oops = { foo: "foo" | "bar" };
const oops: Oops = { foo: "bar" };
myFunc(oops, "foo");  // legal call - no error
console.log(oops.foo);  // type is "foo" | "bar" but prints "modified"

fatcerberus avatar Nov 28 '22 04:11 fatcerberus

@fatcerberus thanks for looking at this! I see what you mean, but this raises another can of worms. Please see this snippet below. Comments:

  • CODE MARKER 1: if I got your comment right, then that line should also raise a compiler error. Yet it doesn't
  • CODE MARKER 2: looks like the compiler just allowed an unsafe operation to go through. Likely because CODE MARKER 1 really should have produced an error

So now there are two things to look at:

  1. Help advise what might be a good way to achieve what I intended in case that error is indeed legit -- how to declare a function that expects a given field of an object to be of a specific type
  2. Review the related bug that the snippet below highlights

Playground link

function myFunc<T extends { field1: string }>(obj: T) {
  // CODE MARKER 1: per fatcerberus's comment, the following should also produce ts2322. But it compiles just fine
  obj.field1 = "modified";
}

type Oops = { field1: "foo" | "bar" };
const oops: Oops = { field1: "bar" };

// CODE MARKER 2: `myFunc` is about to set `oops.field1` to an unacceptable value. And the compiler didn't produce any errors
myFunc(oops);

davidnx avatar Nov 28 '22 08:11 davidnx

Good catch; Marker 1 should be an error for the same reason the equivalent line in the OP is. Not sure what's going on there. Might be you've found an actual bug.

on a side note: I'll note for the record that { field: "foo" | "bar" } being assignable to { field: string } at all is inherently unsound, as objects are passed by reference, but this loophole exists because it's quite common in real-world code to use narrower objects in calls that ask for a wider type, but less common to mutate them across call boundaries. TS is only stricter with generics because writing a generic is an acknowledgement that the actual type of a value will vary, so the compiler tries to ensure that it's valid for all such types. tl;dr, "having a string property" is not the same thing as "having a property whose value is assignable to string", as the former implies assignability in both directions.

fatcerberus avatar Nov 28 '22 15:11 fatcerberus

The error is less about writing to a field with a type that matches the constraint, but rather that the value might not even align as a supertype of the constraint at all when the function is called with a union type as the key. In this case there's a RequireStringField constraint that prevents that, but TS doesn't realize that this has an impact on anything.

RyanCavanaugh avatar Dec 02 '22 22:12 RyanCavanaugh

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

typescript-bot avatar Dec 05 '22 20:12 typescript-bot