Skip to content

[nightly][regression] Mapped type over generic key unexpectedly makes key treated as optional #57860

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
MichaelMitchell-at opened this issue Mar 20, 2024 · 6 comments Β· Fixed by #57946
Assignees
Labels
Bug A bug in TypeScript Fix Available A PR has been opened for this issue Recent Regression This is a new regression just found in the last major/minor version of TypeScript.

Comments

@MichaelMitchell-at
Copy link
Contributor

MichaelMitchell-at commented Mar 20, 2024

πŸ”Ž Search Terms

regression mapped type generic intersection undefined optional

πŸ•— Version & Regression Information

  • This changed in commit or PR #57549

⏯ Playground Link

https://www.staging-typescript.org/play?ts=5.5.0-dev.20240319#code/JYWwDg9gTgLgBAbwF4F84DMoRHA5EiAE1wG4BYAKEsoGMIA7AZ3kZoAsBTEAQzgF44SAHQQARgCsONGAAoElOIrhgsYAFyChzKMHoBzGQEoANJRSHyVK-IpK4MAJ5gOcACIcdANw6EAKk5cBYV10DwAeR2cIdDhWTh4APksFJUiXACFuQlduGF4BBDhgQg1tXT04NAAyNw9gbz8A5NsldABXemlgBjhRLIBRAA9ucAAbDjCAaTgOQZgOekJGOABrDgdouEzs3O4EmUJdjQAFYBoVsNPzsO2cvOM4aYAfPGLcBIfJhMMTs4vb3afBKIFJ2RRQDgwNpQehwQ55Sx2AD0SMUAD0MZi4P5nHgrhd8WE7txPnAXgAiYrkj6PBK4IrLegQeDcRiMYB6ejcUTjewQewBPF-ImA2m4ISguAosEynEuXDEgDaxwAumS4B1CBx0LofPTgIzmXBWezOdzeTB+Wk8ErVeLJdKZbLBbhNdrdcQGXAmSy2RyuTyXJaBbiFbtlSr7S1FI6nc7Q26dfQ9V6fca-WbA3yQ-KyvpxTIAEwAZkLhcMdklKDM1AoNjs1qGYFGZ2AMDl-BB0bBKgg6liMB0+kRSmrVgbgoA4hAiMTO4ViqVB+VKnAak2WzQ23LmnZ2p0YN1YXoZ4QhiNmxNprN5otlmsNjFp7Pdvt4dxftdCc+dvdHurcDeGkvh+OBv1PYkgS7OMIShGE4V2EdFDHFAgA

πŸ’» Code

import {z} from 'zod';


const schema = z.object({
    prop: z.string(),
});


{
    type DerivedType = z.infer<typeof schema>;

    type BadData = { id: string } & DerivedType;

    function badExample<K extends keyof BadData>(data: Pick<Pick<BadData, K | 'id'>, K>): Pick<BadData, K> {
        return data;
    //  ^^^^^^ Type 'Pick<Pick<Data, K | "id">, K>' is not assignable to type 'Pick<Data, K>'.
    //           Type 'Data[P] | undefined' is not assignable to type 'Data[P]'.
    //             Type 'undefined' is not assignable to type 'Data[P]'.
    //               Type 'undefined' is not assignable to type 'string'.(2322)    
    }
}

{
    type ExplicitType = {
        prop: string;
    }

    type GoodData = { id: string } & ExplicitType;

    function goodExample<K extends keyof GoodData>(data: Pick<Pick<GoodData, K | 'id'>, K>): Pick<GoodData, K> {
        return data;
    }
}

πŸ™ Actual behavior

Type error

πŸ™‚ Expected behavior

No type error. DerivedType expands to ExplicitType, yet produces different results.

Additional information about the issue

The title of this issue is probably inaccurate/misleading. I just wasn't sure how to describe the behavior. Note that I discovered this while using Airtable's internal schema lib, yet fortunately I was also able to reproduce this with Zod despite the implementations of the infer type being entirely different.

I bisected this to this commit 3b1b82a with @ahejlsberg's PR #57549

every-ts switch 309fd3db81955ef7a4dd55a80e333b2b767717a7
remote: Enumerating objects: 259180, done.
remote: Counting objects: 100% (379/379), done.
remote: Compressing objects: 100% (316/316), done.
remote: Total 259180 (delta 148), reused 230 (delta 41), pack-reused 258801
Receiving objects: 100% (259180/259180), 908.24 MiB | 7.64 MiB/s, done.
Resolving deltas: 100% (154585/154585), done.
remote: Enumerating objects: 868, done.
remote: Counting objects: 100% (847/847), done.
remote: Compressing objects: 100% (394/394), done.
remote: Total 868 (delta 486), reused 668 (delta 453), pack-reused 21
Receiving objects: 100% (868/868), 3.10 MiB | 10.90 MiB/s, done.
Resolving deltas: 100% (495/495), done.
Updating files: 100% (898/898), done.
Previous HEAD position was e5bf594753 Infer type predicates from function bodies using control flow analysis (#57465)
HEAD is now at 309fd3db81 Revert PR 56161 (#57853)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect bad 
You need to start by "git bisect start"

Do you want me to do it for you [Y/n]? y
status: waiting for both good and bad commits
status: waiting for good commit(s), bad commit known
$ every-ts bisect good v5.4.2
Bisecting: a merge base must be tested
remote: Enumerating objects: 2, done.
remote: Counting objects: 100% (2/2), done.
remote: Compressing objects: 100% (2/2), done.
remote: Total 2 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (2/2), 76.47 KiB | 2.64 MiB/s, done.
[d04e3489b0d8e6bc9a8a9396a633632a5a467328] Improve apparent type of mapped types (#57122)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect good       
Bisecting: 52 revisions left to test after this (roughly 6 steps)
remote: Enumerating objects: 207, done.
remote: Counting objects: 100% (185/185), done.
remote: Compressing objects: 100% (57/57), done.
remote: Total 207 (delta 141), reused 128 (delta 128), pack-reused 22
Receiving objects: 100% (207/207), 1.06 MiB | 6.50 MiB/s, done.
Resolving deltas: 100% (153/153), done.
[353ccb7688351ae33ccf6e0acb913aa30621eaf4] Ensure correct script kind and text when using cached sourceFile from scriptInfo (#57641)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect bad  
Bisecting: 26 revisions left to test after this (roughly 5 steps)
remote: Enumerating objects: 253, done.
remote: Counting objects: 100% (230/230), done.
remote: Compressing objects: 100% (75/75), done.
remote: Total 253 (delta 163), reused 155 (delta 155), pack-reused 23
Receiving objects: 100% (253/253), 604.74 KiB | 8.28 MiB/s, done.
Resolving deltas: 100% (175/175), done.
[a77370342dcdbd9273fe1716390d90a9b0d88fcc] Close "Design Limitation" automatically (#57554)
Building TypeScript...
announce
TypeScript built successfully!
$ announce
$ every-ts bisect good ; announce
Bisecting: 13 revisions left to test after this (roughly 4 steps)
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (7/7), done.
remote: Total 7 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (7/7), 882.83 KiB | 5.38 MiB/s, done.
[877d9d316dd69ebd8253fe4c8915800415b0f455] Intl.NumberFormat: Add latest options, fix previous library discrepancies (#56902)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect good ; announce
Bisecting: 6 revisions left to test after this (roughly 3 steps)
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
Receiving objects: 100% (1/1), 550.26 KiB | 5.79 MiB/s, done.
[1d6d962d3132a901f5fb48be4a389c45faf5a74e] Update package-lock.json
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect good ; announce
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[320e17f1225e1a4f8fcf65554fe0ba2405d8a27f] "Annotate" exported object to fix named / namespace imports of our API in Node ESM (#57133)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect good ; announce
Bisecting: 1 revision left to test after this (roughly 1 step)
[3b1b82a6bf9267b4c99cbd8c4f25a3e578da0d44] Add optionality to mapped type indexed access substitutions (#57549)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect bad ; announce 
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[8336042ea3f84ad536a190ea75e47e74414e8081] Bump the github-actions group with 2 updates (#57624)
Building TypeScript...
TypeScript built successfully!
$ every-ts bisect good ; announce
3b1b82a6bf9267b4c99cbd8c4f25a3e578da0d44 is the first bad commit
@Andarist
Copy link
Contributor

This is likely the same thing that I've raised here. zod's objects outputs (see here) are typed using their addQuestionMarks utility which is defined like this:

  type requiredKeys<T extends object> = {
    [k in keyof T]: undefined extends T[k] ? never : k;
  }[keyof T];

  export type addQuestionMarks<
    T extends object,
    R extends keyof T = requiredKeys<T>
  > = Pick<Required<T>, R> & Partial<T>;

So their definitions always contain a possibility of an optional property. This type is not generic though and the fact that its origin is in this mapped type is something that the user doesn't really ever see so it's a surprising behavior.

@RyanCavanaugh
Copy link
Member

We'd need a standalone repro (i.e. one that doesn't import anything, certainly not something as complex as zod) here to investigate further

@RyanCavanaugh RyanCavanaugh added the Needs More Info The issue still hasn't been fully clarified label Mar 20, 2024
@Andarist
Copy link
Contributor

@RyanCavanaugh I could try to boil down Zod's case to the bare minimum but from what I understand the underlying root cause for this in Zod is the same as in the comment I've quoted. Copying the mentioned code there (TS playground):

type Obj = {
  a: string;
  b: number;
};

type Obj2 = {
  b: number;
  c: boolean;
};

declare const mapped: {
  [K in keyof (Partial<Obj> & Required<Obj2>)]: number;
};

// displays the same way as `resolved` below!
mapped;
// ^? const mapped: { a?: number | undefined; b: number; c: number; }

const accessMapped = <K extends keyof Obj2>(key: K) => mapped[key].toString(); // error, a mystery to the user

declare const resolved: { a?: number | undefined; b: number; c: number };

const accessResolved = <K extends keyof Obj2>(key: K) => resolved[key].toString(); // since this is OK

@RyanCavanaugh RyanCavanaugh added Needs Investigation This issue needs a team member to investigate its status. and removed Needs More Info The issue still hasn't been fully clarified labels Mar 20, 2024
@RyanCavanaugh
Copy link
Member

Above example bisects to the same PR, to be clear

@MichaelMitchell-at
Copy link
Contributor Author

@Andarist beat me to it, but I reduced it down to this, which I think more strongly shows that something is wrong

type EmptyObject = {[K in never]?: never};  // This is just `{}` ?

type BadData = { id: string, prop: string } & EmptyObject;

function badExample<K extends keyof BadData>(data: Pick<Pick<BadData, K | 'id'>, K>): Pick<BadData, K> {
    return data;
    //     ^^^^Same error
}

@ahejlsberg
Copy link
Member

Even simpler repro:

type Foo = {
    prop: string;
}

function test<K extends keyof Foo>(obj: Pick<Required<Foo> & Partial<Foo>, K>, key: K) {
    obj[key].length;  // Error: Object is possibly 'undefined'
}

@ahejlsberg ahejlsberg added Bug A bug in TypeScript Recent Regression This is a new regression just found in the last major/minor version of TypeScript. and removed Needs Investigation This issue needs a team member to investigate its status. labels Mar 21, 2024
@ahejlsberg ahejlsberg added this to the TypeScript 5.5.0 milestone Mar 26, 2024
@ahejlsberg ahejlsberg added the Fix Available A PR has been opened for this issue label Mar 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Fix Available A PR has been opened for this issue Recent Regression This is a new regression just found in the last major/minor version of TypeScript.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants