Skip to content

Mapped types does not work with generic type arguments extending an object #35647

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
HosseinAgha opened this issue Dec 12, 2019 · 13 comments
Closed
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@HosseinAgha
Copy link

TypeScript Version: 3.7.x-dev.201xxxxx

Search Terms: mapped types generics bug

Code

I can confirm that this does not work:

  const genericFunc = async <Schema extends { _id: string }>() => {
    type a = { [K in keyof Schema]: Schema[K] };
    const b: a = { _id: 'sd' };
  }

And this does work:

  type Schema2 = { _id: string };
  type a2 = { [K in keyof Schema2]: Schema2[K] };
  const b2: a2 = { _id: 'sd' };

Expected behavior:
IMO TypeScript should assume generic parameter above is something like this:

interface Schema {
  _id: string;
  [K: string]: any;
}

and does not throw errors.

Playground Link: example link
Related Issues: I'm one of the maintainers of the @types/mongodb in definitely typed. This is the original issue DefinitelyTyped/DefinitelyTyped#39358

@HosseinAgha HosseinAgha changed the title Mapped types does not work with generic type parameters Mapped types does not work with generic type arguments extending an object Dec 12, 2019
@AnyhowStep
Copy link
Contributor

#35077

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Dec 12, 2019

This is a correct error, because the code makes a promise it's not keeping - specifically that for any T, a valid Partial<T> is produced.

// Is correctly identified as an error:
function genericMethodWithExtends<T extends Schema>(): Partial<T> {
    type PartialSchema2 = Partial<T>;
    const b1: PartialSchema2 = { _id: 'sd' };
    return b1;
}

type MySchema = { id?: "foo" }
const k = genericMethodWithExtends<MySchema>();
// The only legal inhabitants of 'v' are "foo" and "undefined", but
// v has value "sd" instead.
const v = k.id;

https://twitter.com/SeaRyanC/status/1121995986862084096

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Dec 12, 2019
@fatcerberus
Copy link

Every drink in a bar comes in a glass (the constraint), but the waiter (the function) can't just bring you an empty glass (the return value) if you asked for a beer (T)

Well I mean... maybe the barkeep is just cutting you off because you've already ordered too many drinks that night?

@typescript-bot
Copy link
Collaborator

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

@HosseinAgha
Copy link
Author

HosseinAgha commented Dec 17, 2019

I understand the classic OOP argument:

class Cat extends Animal {}

// this is "correct": as Cat is bigger than Animal so we won't see a wrong property access
const a: Animal = new Cat();
// but this is "wrong": as you may access a property on Cat that does not exist on Animal
const c: Cat = new Animal();

You are right, I did not consider string/numeric literal types when I made my argument. In other words I considered string (and any other Basic Type) not extendable.

So in a language without string/numeric literal types the T extends string means T == string.

I still have some unanswered questions:

1) I still don't understand why the following throws?

function genericMethodWithExtends<T extends 'sd'>(): T {
    return 'sd';
}

We've reached the root of type hierarchy here, nobody can extend 'sd' that causes this to break.

2) considering string literal types, does TS infer incorrect types here?

const a = { p1: 12, p2: 'sdsdf' }
// type of a is: { p1: number, p2: string }
// it should be: { p1: 12, p2: 'sdsdf' }

3) Why keyof value is messed up here?

const myFunction = <T extends { _id: string }>(): Promise<void> => {
  type AType = keyof T extends '_id' ? string : number;
  // this throws error
  let a: AType = 'apples';
  // this also throws error
  let a: AType = 4;
}

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Dec 17, 2019

function genericMethodWithExtends<T extends 'sd'>(): T {
    return 'sd';
}

There's never, "sd" & { aBrandType : void }, etc.


const a = { p1: 12, p2: 'sdsdf' } as const

const myFunction = <T extends { _id: string }>(): Promise<void> => {
  type AType = keyof T extends '_id' ? string : number;
  // this throws error
  let a: AType = 'apples';
  // this also throws error
  let a: AType = 4;
}

Evaluating generic conditional types is deferred because it doesn't know what T is.

T could be { _id: string }, or it could be { _id: string, qwerty : number }

@HosseinAgha
Copy link
Author

Thank you @AnyhowStep.

  1. looks right. Although "sd" & { aBrandType : void } is a little bit strange (I guess everything is an object in JavaScript 😃)

  1. what does const a = { p1: 12, p2: 'sdsdf' } as const mean?

  1. T could be { _id: string }: isn't this enough to resolve this conditional type keyof T extends '_id' ? string : number;?

@AnyhowStep
Copy link
Contributor

what does const a = { p1: 12, p2: 'sdsdf' } as const mean?

It's a "const assertion"

http://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions


T could be { _id: string }: isn't this enough to resolve this conditional type keyof T extends '_id' ? string : number;?

If T is { _id : string, x : number }, then keyof T is "_id"|"x".

"_id"|"x" does not extend "_id".

It is possible for the conditional type to evaluate to the true branch. Also possible to evaluate to the false branch. It depends on what T is.

@jimbuck
Copy link

jimbuck commented Dec 17, 2019

I totally follow the original question and answer (all Beers are Beverages, all Beverages are not Beers), but not @RyanCavanaugh's answer (why is there id and _id). How is Partial<T> supposed to fit in with all of this? Given the following code:

Playground

interface Schema {
  id: string;
}

function genericMethodWithExtends<T extends Schema>(): void {
  type PartialOfT      = Partial<T>;
  type PartialOfSchema = Partial<Schema>;
  
  const b1: PartialOfSchema = { id: 'sd' }; // <- This is valid.
  const b2: PartialOfT      = { id: 'sd' }; // <- This is not. Why?
}

type MySchema = { name: string, id: string }
genericMethodWithExtends<MySchema>();

Should the assignment to a Partial<T> fail? If Partial<T> is "a type that represents all subsets of a given type" then surely the assignment to b1 (above) should be valid. Or are generic subclasses not supported by Partial<T> currently? Or is there a better way to model these types?

To continue the glass/beer analogy: The waiter gave me my beer in a glass and I promise to return the glass and/or the beer.

And for reference, the types created above show up in intellisense like so:

type PartialOfT = { [P in keyof T]?: T[P] | undefined; }
type PartialOfSchema = { id?: string | undefined; }

@AnyhowStep
Copy link
Contributor

Consider T = { id : "lol" }.

type PartialOfT = { id? : "lol"|undefined };
const b2: PartialOfT = { id : "sd" }; //Makes sense this is an error

@HosseinAgha
Copy link
Author

HosseinAgha commented Dec 18, 2019

@jimbuck as @AnyhowStep said the problem with your code again is that you should also consider that string can have infinite sub types. so "sd" is a string and "lol" is a string but "sd" != "lol". The trick is that "sd" is a type in TypeScript (unlike some other languages) and it extends string.

@jimbuck
Copy link

jimbuck commented Dec 18, 2019

Ahh, the thing that got me was the fact that when you extend an interface/type you can "override" a string property with a string literal. I've updated my code to highlight this a little bit better:

Playground

interface Schema {
  id: string;
}

function genericMethodWithExtends<T extends Schema>(): void {
  type PartialOfT      = Partial<T>;
  type PartialOfSchema = Partial<Schema>;
  
  const b1: PartialOfSchema = { id: 'sd' }; // <- This is valid.
  const b2: PartialOfT      = { id: 'sd' }; // <- For T this is supposed to be 'lol', hence the error.
}

interface MySchema extends Schema {
  id: 'lol' // This can be "overriden" since 'lol' is a string (no rules broken)
}
genericMethodWithExtends<MySchema>();

I know that 'lol' is still a string, but it really is changing the type of the property on the interface, which is a little annoying to me. Either way, thank you for providing some insight on this scenario!!

@AnyhowStep
Copy link
Contributor

AnyhowStep commented Dec 18, 2019

It's not even really about interface subtyping.

type { id : "lol" } is a subtype of Schema.

Even though { id : "lol" } is not an interface with an explicit extends Schema annotation.

alecgibson added a commit to alecgibson/monorepo that referenced this issue Dec 10, 2024
Fixes inversify#170

At the moment it's impossible to create generic helper functions to
deal with `TypedContainer`:

```ts
interface MyMap {
  foo: string;
}

function fn<T extends MyMap>(container: TypedContainer<T>) {
  container.get('foo') // error
}
```

This is because of the `Synchronous` type that guards against calling
`.get()` on `Promise` bindings. Under the hood, this type is a mapped
type, which [doesn't work well with generics][1] (by design).

Rather than drop this guard all together, this change aims to strike a
balance by removing the `Synchronous` mapped type, and instead changing
the return type of synchronous `get()` methods to be `never` if the
binding is a `Promise`.

This won't error as obviously or as immediately as before, but will
still at least flag to the developer semantically that this binding will
never return a value (since it will throw), and should cause compilation
errors if consumers try to do anything with the returned value.

In return, we gain the ability to use generic helper functions.

[1]: microsoft/TypeScript#35647
alecgibson added a commit to alecgibson/monorepo that referenced this issue Dec 10, 2024
Fixes inversify#170

At the moment it's impossible to create generic helper functions to
deal with `TypedContainer`:

```ts
interface MyMap {
  foo: string;
}

function fn<T extends MyMap>(container: TypedContainer<T>) {
  container.get('foo') // error
}
```

This is because of the `Synchronous` type that guards against calling
`.get()` on `Promise` bindings. Under the hood, this type is a mapped
type, which [doesn't work well with generics][1] (by design).

Rather than drop this guard all together, this change aims to strike a
balance by removing the `Synchronous` mapped type, and instead changing
the return type of synchronous `get()` methods to be `never` if the
binding is a `Promise`.

This won't error as obviously or as immediately as before, but will
still at least flag to the developer semantically that this binding will
never return a value (since it will throw), and should cause compilation
errors if consumers try to do anything with the returned value.

In return, we gain the ability to use generic helper functions.

[1]: microsoft/TypeScript#35647
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

6 participants