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

Optional properties coming from conditional spread lose "| undefined" #41418

Closed
danvk opened this issue Nov 5, 2020 · 11 comments · Fixed by #42233
Closed

Optional properties coming from conditional spread lose "| undefined" #41418

danvk opened this issue Nov 5, 2020 · 11 comments · Fixed by #42233
Assignees
Labels
Bug A bug in TypeScript

Comments

@danvk
Copy link
Contributor

danvk commented Nov 5, 2020

TypeScript Version: 4.2.0-dev.20201105

Search Terms:

  • spread optional

Expected behavior:

The start symbols should both have type number | undefined.

Actual behavior:

The one destructured from the conditional spread has somehow dropped the undefined.

Related Issues:

#39376

Code

I'd expect the two start variables in the following code to have the same type. But they don't, one is number, whereas the other is number | undefined:

declare let hasDates: boolean;

{
  const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
  const pharaoh = {
    ...nameTitle,
    ...(hasDates && { start: -2589, end: -2566 }),
  };
  // type is const pharaoh: {
  //     start?: number;
  //     end?: number;
  //     name: string;
  //     title: string;
  // }, which is character-for-character identical to Reign, below.
  const { start } = pharaoh;
  // const start: number
}

interface Reign {
  start?: number;
  end?: number;
  name: string;
  title: string;
}
declare let reign: Reign;

{
  const { start } = reign;
  // const start: number | undefined
}
Output
"use strict";
{
    const nameTitle = { name: 'Khufu', title: 'Pharaoh' };
    const pharaoh = Object.assign(Object.assign({}, nameTitle), (hasDates && { start: -2589, end: -2566 }));
    // type is const pharaoh: {
    //     start?: number;
    //     end?: number;
    //     name: string;
    //     title: string;
    // }, which is character-for-character identical to Reign, below.
    const { start } = pharaoh;
    // const start: number
}
{
    const { start } = reign;
    // const start: number | undefined
}
Compiler Options
{
  "compilerOptions": {
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictPropertyInitialization": true,
    "strictBindCallApply": true,
    "noImplicitThis": true,
    "noImplicitReturns": true,
    "alwaysStrict": true,
    "esModuleInterop": true,
    "declaration": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": 2,
    "target": "ES2017",
    "jsx": "React",
    "module": "ESNext"
  }
}

Playground Link: Provided

@danvk danvk changed the title Optional properties coming from spread operator are lost Optional properties coming from conditional spread lose "| undefined" Nov 5, 2020
@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label Nov 6, 2020
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 4.1.1 milestone Nov 6, 2020
@RyanCavanaugh
Copy link
Member

@andrewbranch I think we should consider for 4.1 at least; this seems bad

@andrewbranch
Copy link
Member

This is very interesting. It’s actually been a bug at least since 3.8, with #34853, but the new behavior of conditional spreads creating optional properties increases your chances of observing it. Here’s an example of the bug in 3.8.3.

Basically, as far as I can tell, we’ve never had the necessary machinery to mix optionality into the type retrieved from a synthetic optional property. The place where we union with undefined for optional parameter declarations and property signatures actually looks at the declaration node for the ? token—we never even look at SymbolFlags.Optional for this purpose.

Despite having diagnosed the problem, I’m having trouble fixing this in a way that doesn’t broadly change the type printout of these synthetic optional property symbols from x?: number to x?: number | undefined. The couple different places where you could add the optionality also come into play in the node builder. I’m assuming the reason this isn’t a problem for properties that can be traced back to an optional property signature is because we simply use that property signature node as part of the printout. I’m not sure the best way to proceed—I think it would be ok to just tweak the node builder such that if a property is both optional and synthetic, and its type is a union, we can filter undefinedType from the union. But maybe there’s a more subtle option I’m missing. /cc @weswigham

@weswigham
Copy link
Member

Hm, that doesn't seem so bad, at least. Certainly better than a synthetic optional property declaration or the like. The other option would be adding a SymbolLink flag that represents "should this property have undefined mixed in" (the thing we directly look up from the declaration). Essentially adding a cache for the declaration lookup, then directly setting that cache in synthetic property situations like this.

@ahejlsberg
Copy link
Member

Why is it a problem that the printout changes to x?: number | undefined. We do that in other places anyway. For example:

type Foo = (x?: number) => void;  // Prints back as (x?: number | undefined) => void

Or am I misunderstanding something?

@andrewbranch
Copy link
Member

It’s not a huge problem, it just changes a bunch of seemingly unrelated baselines, and is more verbose while conveying no more information, so it seems undesirable all other things held equal.

@ahejlsberg
Copy link
Member

is more verbose while conveying no more information, so it seems undesirable all other things held equal.

I tend to agree, but that is nonetheless what we currently do for print back of optional properties. If we're going to change it, we should change it across the board, not just in this targeted scenario.

@andrewbranch
Copy link
Member

Huh, I must have been confused by existing baselines that already exhibit this bug, because I was confident this didn’t happen for property signature printback, but apparently I’m wrong. In that case, I think this is a small change, and we can look at making printback more concise separately.

@ahejlsberg
Copy link
Member

Actually, I think the current scheme does convey important information. The type of an optional property really is xxx | undefined since that's what you get when you read the property and that's what you're allowed to assign to the property. It's just that instead of complaining that x?: number is an invalid declaration, as a convenience we automatically union in undefined.

@andrewbranch
Copy link
Member

Hmm, actually this causes another break. Consider this case:

declare let r: { [key: string]: number };

declare let fromAnnotation: { x?: number };
r = { ...fromAnnotation }; // Error: Type 'undefined' is not assignable to type 'number'.

let fromExpression = { ...!!true ? { x: 3 } : {} };
r = { ...fromExpression }; // Ok

The latter works today, and is asserted in a test, only because when we get the type of the x property in fromExpression, we incorrectly do not include undefined. If we fix that type, both assignments error, which is... a win for consistency, and I don’t know how bad the bad consequences are—but it is a breaking change.

@danvk
Copy link
Contributor Author

danvk commented Nov 13, 2020

@andrewbranch much of the discussion in the Design Meeting was about index types. I don't entirely see the connection. Is an index signature somehow involved behind the scenes here?

I noticed that the example in the release notes for TS 4.1 RC is also broken because of this issue:

interface Person {
    name: string;
    age: number;
    location: string;
}

interface Animal {
    name: string;
    owner: Person;
}

function copyOwner(pet?: Animal) {
    return {
        ...(pet && pet.owner),
        otherStuff: 123
    }
}

const owner = copyOwner();

const loc = owner.location;  // type is string, should be string | undefined
console.log(loc.blink());
// 💥 Cannot read property 'blink' of undefined

playground

@andrewbranch
Copy link
Member

@danvk a lot of the conversation focused on index signatures because fixing this bug breaks an existing test where an object with optional properties is asserted to be assignable to an index signature. There is no disagreement that the type of the property is a bug, but we can’t fix it this late in the release cycle because the bug has been around at least since 3.8, and fixing it is a breaking change. So, much of the discussion focused on the question of when we do fix the property type, should we consider additional changes to make the assignability of optional properties to index signatures less breaky.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants