Skip to content

Difficult to implement foo<K extends keyof FooMap>(k: K): FooMap[K] #31904

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

Closed
jdanyow opened this issue Jun 14, 2019 · 13 comments
Closed

Difficult to implement foo<K extends keyof FooMap>(k: K): FooMap[K] #31904

jdanyow opened this issue Jun 14, 2019 · 13 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@jdanyow
Copy link

jdanyow commented Jun 14, 2019

TypeScript Version: 3.5.2, 3.5.1 (didn't test in 3.5.0)
Does not reproduce in 3.4.5

Search Terms:

  • 2332
  • mapped type
  • keyof

Code

export interface Dog {
    bark: string;
}

export interface Cat {
    meow: string;
}

export interface AnimalMap {
    'dog': Dog;
    'cat': Cat;
}

export function getAnimal<T extends keyof AnimalMap>(kind: T): AnimalMap[T] {
    switch (kind) {
        case 'dog':
            // Type '{ bark: string; }' is not assignable to type 'AnimalMap[T]'.
            // Type '{ bark: string; }' is not assignable to type 'Dog & Cat'.
            //    Property 'meow' is missing in type '{ bark: string; }' but required in type 'Cat'.ts(2322)
            return { bark: 'woof' };
        case 'cat':
            // Type '{ meow: string; }' is not assignable to type 'AnimalMap[T]'.
            // Type '{ meow: string; }' is not assignable to type 'Dog & Cat'.
            //    Property 'bark' is missing in type '{ meow: string; }' but required in type 'Dog'.ts(2322)
            return { meow: 'meow' }; 
        default:
            throw new Error('nope');
    }
}

const dog = getAnimal('dog'); // inferred as Dog

Expected behavior:

I didn't expect this to be an error when upgrading from 3.4.5 to 3.5.2. If calling getAnimal with "dog" infers a return type of Dog, shouldn't the same inference apply within the getAnimal function body?

I learned the pattern for this function signature from looking at the querySelector interface in lib.dom.d.ts

querySelector<K extends keyof HTMLElementTagNameMap>(selectors: K): HTMLElementTagNameMap[K] | null;

Actual behavior:

Type '{ bark: string; }' is not assignable to type 'AnimalMap[T]'.
  Type '{ bark: string; }' is not assignable to type 'Dog & Cat'.
    Property 'meow' is missing in type '{ bark: string; }' but required in type 'Cat'.ts(2322)

Playground Link note: playground is on older version of typescript, issue doesn't reproduce there

@fkleuver
Copy link

Ran into a similar issue while upgrading Aurelia's TS version. It actually broke a lot of the project because we're doing various kinds of exotic strongly typed object cloning/overwriting.

It seems like the assignment to a obj[prop as keyof typeof obj] expression gets a different type (an intersection type) than the evaluation result of that same expression (a union type).

It used to be a union type for both cases in 3.4.5 and earlier versions. In 3.5.1 it suddenly became an intersection type.

Another example to illustrate this problem:

interface IThing {
  str: string;
  num: number;
}

const thing1: IThing = { str: '', num: 0 };
const thing2: IThing = { str: '', num: 0 };

for (const prop in thing2) {
  thing1[prop as keyof IThing] = thing2[prop as keyof IThing];
}

image

At some point the types can't be intersected anymore and you get never:

interface IThing {
  str: string;
  num: number;
  bool: boolean;
}

const thing1: IThing = { str: '', num: 0, bool: true };
const thing2: IThing = { str: '', num: 0, bool: true };

for (const prop in thing2) {
  thing1[prop as keyof IThing] = thing2[prop as keyof IThing];
}

image

In a nutshell, it's now impossible to dynamically assign to an object using keyof because the target type is intersected instead of unioned, and therefore always wrong.

Perhaps https://github.com/aurelia/aurelia would be a good integration test case to add to the test suite. We seem to get breakages every release since 3.3.400 ;)

@jdanyow
Copy link
Author

jdanyow commented Jun 15, 2019

It's clear from the error message the compiler is using an intersection type when validating the return expression.

It would be great to get some guidance from the TypeScript team on how to implement functions with interfaces like document.createElement:

createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];

It seems lib.es6.d.ts contains function interfaces that are impossible to implement without resorting to as any.

function createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K] {
	if (tagName === 'a') {
		// Type 'HTMLAnchorElement' is not assignable to type 'HTMLElementTagNameMap[K]'.
		// Type 'HTMLAnchorElement' is not assignable to type 'HTMLObjectElement & HTMLFormElement & HTMLAnchorElement & HTMLElement & HTMLAppletElement & HTMLAreaElement & HTMLAudioElement & HTMLBaseElement & HTMLBaseFontElement & ... 62 more ... & HTMLVideoElement'.
		//   Type 'HTMLAnchorElement' is missing the following properties from type 'HTMLObjectElement': align, archive, border, code, and 21 more.ts(2322)
		return new HTMLAnchorElement(); // ❌ ^^^
	}
	throw new Error('not implemented');
}

const el = createElement('a'); // "el" is inferred to be of type HTMLAnchorElement ✅

@jdanyow jdanyow changed the title Error 2332 in function returning mapped type Difficult to implement foo<K extends keyof FooMap>(k: K): FooMap[K] Jun 15, 2019
@jack-williams
Copy link
Collaborator

This was affected by #30769. The issue is that the old behaviour which didn't report an error way clearly wrong:

export function getAnimal<T extends keyof AnimalMap>(kind: T): AnimalMap[T] {
    switch (kind) {
        case 'dog':
            return { meow: 'meow' };
        case 'cat':
            return { bark: 'woof' };           
        default:
            throw new Error('nope');
    }
}

Ultimately signatures like getAnimal<T extends keyof AnimalMap>(kind: T): AnimalMap[T] are promising contracts that TypeScript can't reasonably enforce right now. In lieu of reverting the breaking change: maybe use an overload signature instead?

export function getAnimal<K extends keyof AnimalMap>(kind: K): AnimalMap[K];
export function getAnimal(kind: keyof AnimalMap): AnimalMap[keyof AnimalMap] {
    ....
}

@fkleuver
Copy link

fkleuver commented Jun 16, 2019

@jack-williams That's just solving one use case with a workaround, but ultimately not addressing the underlying (what I believe to be) flaw of the assignment type being turned into an intersection type.

Applying that trick, for example, doesn't work in the use case I showed. To continue with dogs & cats:

export interface Dog {
  bark: string;
}

export interface Cat {
  meow: string;
}

export interface AnimalMap {
  'dog': Dog;
  'cat': Cat;
}

const animalMap1: AnimalMap = { dog: { bark: 'bark1' }, cat: { meow: 'meow1' } } as any;
const animalMap2: AnimalMap = { dog: { bark: 'bark2' }, cat: { meow: 'meow2' } } as any;

for (const kind in animalMap2) {
  animalMap1[prop as keyof AnimalMap] = animalMap2[kind as keyof AnimalMap] as AnimalMap[keyof AnimalMap];
}

image

How would you solve this case?

And why would the left-hand side and the right-hand side of this expression, which both essentially have the same type, be resolved to different types in the first place? This doesn't seem right no matter how you slice it.

@fkleuver
Copy link

@ahejlsberg I was reading through #30769 just now and saw the various decisions and justifications. I understand now. Still, currently the only solution seems to be to completely toss out type checking with an as any cast on the target object.

When looping over an array of keyof T properties and in a single assignment the same key (same variable) is used on the source as on the target, TypeScript could tell that this is the case: the source and target object types are the same, the type of the key is the same. therefore the assignment target should be the same as the assignment source.

Does that make sense?

@ahejlsberg
Copy link
Member

When looping over an array of keyof T properties and in a single assignment the same key (same variable) is used on the source as on the target, TypeScript could tell that this is the case: the source and target object types are the same, the type of the key is the same. therefore the assignment target should be the same as the assignment source.

This already works for the same T[K] on both sides of an assignment, with the caveat that at least one of T or K must be a generic type. When neither is generic, the checker resolves the indexed access to an actual union (on the source side) or intersection (on the target side) and thus can no longer see that the types are the same.

So, your example above works when you write it this way:

export interface Dog {
  bark: string;
}

export interface Cat {
  meow: string;
}

export interface AnimalMap {
  'dog': Dog;
  'cat': Cat;
}

function assignProps<T extends object>(target: T, source: T) {
    for (const p in source) {
        target[p] = source[p];
    }
}

const animalMap1: AnimalMap = { dog: { bark: 'bark1' }, cat: { meow: 'meow1' } } as any;
const animalMap2: AnimalMap = { dog: { bark: 'bark2' }, cat: { meow: 'meow2' } } as any;

assignProps(animalMap1, animalMap2);

See my comments #30769 (comment) and #30769 (comment) for the rationale.

@jdanyow
Copy link
Author

jdanyow commented Jun 21, 2019

@ahejlsberg thank you so much for looking at this. I think the spirit of my original question might have been lost in some of the discussion. What I'm looking for is how to implement signatures like document.createElement as defined in lib.dom.d.ts.

createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];

function createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K] {
	if (tagName === 'a') {
		// Type 'HTMLAnchorElement' is not assignable to type 'HTMLElementTagNameMap[K]'.
		// Type 'HTMLAnchorElement' is not assignable to type 'HTMLObjectElement & HTMLFormElement & HTMLAnchorElement & HTMLElement & HTMLAppletElement & HTMLAreaElement & HTMLAudioElement & HTMLBaseElement & HTMLBaseFontElement & ... 62 more ... & HTMLVideoElement'.
		//   Type 'HTMLAnchorElement' is missing the following properties from type 'HTMLObjectElement': align, archive, border, code, and 21 more.ts(2322)
		return new HTMLAnchorElement(); // ❌ ^^^
	}
	throw new Error('not implemented');
}

const el = createElement('a'); // "el" is inferred to be of type HTMLAnchorElement ✅

@weswigham
Copy link
Member

What I'm looking for is how to implement signatures like document.createElement as defined in lib.dom.d.ts.

Like @jack-williams implied:

function createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
function createElement(tagName: keyof HTMLElementTagNameMap, options?: ElementCreationOptions): HTMLElementTagNameMap[keyof HTMLElementTagNameMap] {
	if (tagName === 'a') {
		return new HTMLAnchorElement();
	}
	throw new Error('not implemented');
}

const el = createElement('a'); // "el" is inferred to be of type HTMLAnchorElement ✅

@fkleuver
Copy link

Sorry for "hijacking" @jdanyow, I thought our issues seemed similar enough in nature that I should post here instead of creating a new issue. I thought your specific use case was addressed in an earlier comment.

Anyway, my takeaway from all this is that keyof generics have gotten more strict, to the point where the types that are internal to an implementation will not always be able to be the same as those presented in the public API anymore.

So in @jdanyow 's case, it's evident that one overload is needed (which still resolves to a single non-overloaded method in the types) to keep the compiler happy for the implementation whilst keeping the same external signature.
lib.dom.d.ts is kind of "cheating" here because type definitions can do whatever they want; there is no backing TypeScript implementation where a compiler needs to be kept happy.

In my case, an as any cast is unavoidable because my example cannot be rewritten as a generic function where both the source and target are provided as incoming generic types: the target of the assignment is instantiated inside the function, and the class instance - even though it fulfills the generic type constraint of T, cannot be assigned to that type because:

Type 'DefaultTemplateDefinition' is not assignable to type 'T'.
  'DefaultTemplateDefinition' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'ITemplateDefinition'.ts(2322)

And I don't believe there is any way to declare a generic type constraint as covariant or contravariant, is there?

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Jun 25, 2019
@jdanyow
Copy link
Author

jdanyow commented Jun 25, 2019

@weswigham the workaround provided by @jack-williams was great because it enabled us to preserve our existing code. I think there's still an opportunity to improve the ergonomics and type safety. Here's an extended example:

function createElement<K extends keyof HTMLElementTagNameMap>(tagName: K, options?: ElementCreationOptions): HTMLElementTagNameMap[K];
function createElement(tagName: keyof HTMLElementTagNameMap, options?: ElementCreationOptions): HTMLElementTagNameMap[keyof HTMLElementTagNameMap] {
  if (tagName === 'a') {
    return new HTMLAnchorElement();
  }
  if (tagName === 'p') {
    return new HTMLCanvasElement(); // 🐛 bug not caught by compiler due to overload signature.
  }
  throw new Error('not implemented');
}

const el1 = createElement('a'); // "el1" is inferred to be of type HTMLAnchorElement ✅
const el2 = createElement('p'); // 🐛 "el2" is inferred to be of type HTMLParagraphElement but a bug in the method body returned an HTMLCanvasElement

@parkerault
Copy link

parkerault commented Dec 10, 2019

I'm sorry to necropost here, but this is such a nasty gotcha that I am constantly running into some variant of it and frequently spend hours mucking around with different approaches just to get two objects with identical keys to take an assignment. I understand that allowing this to happen is unsound, but it's such a fundamental operation in javascript and it's so frustrating to not be able to write something as simple as a[key] = b[key]. If we try to approach this from the point of view of a novice typescript user (who will no doubt run into this issue almost immediately), what can we do to make this less frustrating? If the user is lucky they will stumble onto the explanation at #30769 after a series of google searches, and then get assaulted by a wall of gobbledygook that may be very clear and concise to the typescript team, but completely opaque to the average developer.

Imagine you are a new front-end developer.

  1. Do a google search for typescript "2322", because you're getting this weird error trying to do something that should be pretty straightforward, right?

  2. Look through dozens of stack overflow questions, only some of which are relevant, and see how long it takes to land on Improve soundness of indexed access types #30769.

  3. "When an indexed access T[K] occurs on the source side of a type relationship, it resolves to a union type of the properties selected by T[K], but when it occurs on the target side of a type relationship, it n̴o̷w̸ ̴r̴e̵s̸o̷l̶v̶e̴s̷ ̸t̵̪͘ȍ̷̡ ̴̞̽a̴̦͗n̵̳͘ ̴̥̏i̵̦̅n̴̘̎t̸̛̞ë̷̪r̴͍̔s̸̜̀̂ḙ̵͝c̶͍͗t̸̢̆̓i̶̭͌̐o̸̻̕n̴͉̿ ̸̘̦̎t̷͔̀y̵͎̱̓p̵̲͑e̷̟͓̬͐͌̕ ̷̨͚́͝ó̷̠f̵̤̎͘ ̷̡͗̒t̴̼̏̀̑h̸̹̓̕ḛ̷̌̓̚ ̷̛̝̼̦͠͝p̴̫̭̽̔̚r̷̖͂̑ͅo̸͉̎͌p̸̯̜̍͛̈́ȩ̶̥̐̚͜r̴̫̐t̴̼̳͌̐ḯ̵͔̹̤e̵͇̒͠ͅs̶̝̙͌̚ ̴̜̹̖͂͝ṣ̸̡̐̏͗́̈́̔e̴̛̲͓͓̤͈͊͊̀̇̿l̶͍̒͊͠e̴̖̜̜̳̟͐̔̈c̷̨̭̖̎̀̋ţ̶̘̜̯͆ȩ̸͔̗̏̈́̊͌̂ď̷̛̼̞̒ͅ ̷͍̤̯̂̏̒̍̍b̶͕̥̝̪̎ȳ̷̮͇̭̒͒ ̸̛̻̟͌̾̒T̷̤̲͙̾͛͠[̸͍̣̇̑́́̊̇K̷̘͍̐͂]̸͈͎͓̞͙͕̉̓͌̚.̸̳̀̓͐̔͠ ̴͚̼̬̭̈̊͝͝P̸̨̧̳̱̜͚͗͛̿r̴̨̢̛͉͖̻͙̆ë̴̡͕̥̪̬̮́̐v̷͔͓͙̭̔̕i̷̼̘̯̫̎͋̅̎̂͋ͅö̵̮́̍̌u̴̮͋̿̀͊͋͘s̵͎͍̰͕̔̊͗͜l̸͍̻̪̓̇́̓ŷ̸͔̼̉̈̂̌̐,̷̢̢͚̼͓̦̈́̐ͅ ̵̯̟͇̫̂͒̾̽̌̃̃̍̕͝t̴͕̣͕̳̹̆́̀

  4. Decide that this is not something you can tackle right now, because you are trying to finish your tickets.

  5. Scroll down looking for a workaround (surely someone has posted a snippet that I can copy and paste to fix this, right?), which is apparently to write some intermediate functions that take generic indexed type parameters with type constraints and an overload TCat extends IAnimal infer keyof typeof IDog in K? What's a generic?

  6. Go hide in the bathroom and cry for a while.

  7. Just change all of your types to any, after spending 2 hours feeling bad about being so dumb and suffering a major episode of impostor syndrome.

I think this is one of those cases where improving the UX is much simpler and more effective than a technical solution. Perhaps giving this particular issue a unique error that leads from google to a page in the handbook that shows a handful of examples and workarounds, and links to a more in-depth explanation for those that are interested. All I know is that I've been doing this since we were partying like it was 1999, and I still can't quite figure out how to avoid this problem. It's not enough to tell people their code is unsound if we can't tell them how to make it sound.

@fkleuver
Copy link

A unique error code doesn't sound like a bad idea, but that requires detecting this particular situation in the TS compiler. If I'm not mistaken, that's part of the challenge::

Certainly possible, yes, just a massively complex undertaking we're not inclined to tackle at this point.

It's currently classified as a design limitation:
#31445
#35140

I don't know if a shortcut might be possible to make it easier. For instance, if an intersection type resolves to never, I think there's a solid 99% chance it has to do with those issues.

@nisimjoseph
Copy link

I just encounter this issue as well. I see it closed but no fix done on it.
any update on it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

9 participants