Skip to content

Pick doesn't preserve optional from unions #28483

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
RomanHotsiy opened this issue Nov 12, 2018 · 10 comments
Closed

Pick doesn't preserve optional from unions #28483

RomanHotsiy opened this issue Nov 12, 2018 · 10 comments
Labels
Domain: Mapped Types The issue relates to mapped types Needs Investigation This issue needs a team member to investigate its status.

Comments

@RomanHotsiy
Copy link

RomanHotsiy commented Nov 12, 2018

TypeScript Version: 3.2.0-dev.20181110

Search Terms: Pick preserve optional union

Code

type A = {
  optional?: string;
  other: string;
}

type B = {
  optional?: string;
  other: string;
}

type SimplePick = Pick<A, 'optional'>
/*
{
    optional?: string | undefined;
}
*/

type PickUnion = Pick<A | B, 'optional'>
/*
{
    optional: string | undefined; // <--- note it is not optional
}
*/

type A = {
  optional?: string;
  other: string;
}

type B = {
  optional?: string;
  other: string;
}

let good: SimplePick = {}; // works just fine
let fails: PickUnion = {}; // Property 'optional' is missing in type '{}'.

Expected behavior:
Pick should preserve optional property when used on unions

Actual behavior:
It doesn't

Playground Link: link

@weswigham weswigham added the Needs Investigation This issue needs a team member to investigate its status. label Nov 12, 2018
@weswigham
Copy link
Member

At present this is a design limitation - mapped types only preserve the modifiers of their inputs when the input key type is (almost exactly) keyof T for some T. It might be possible to expand this to intersections of keyof types (although would such a change be viable at this point without breaking people?) - would need to investigate that.

@weswigham weswigham added the Domain: Mapped Types The issue relates to mapped types label Nov 12, 2018
@jcalz
Copy link
Contributor

jcalz commented Nov 12, 2018

Wait, don't union types lose optional modifiers on common properties anyway? Even without mapped types, that is:

type U = A | B;
declare const u : U;
u.optional // not listed as an optional property anymore

@RomanHotsiy
Copy link
Author

@jcalz it looks that they don't.

Given the example in Playground I linked above the following code doesn't show any type errors:

let works: A | B = { other: '' };

Updated Playground Link

@jcalz
Copy link
Contributor

jcalz commented Nov 13, 2018

Sure, but that's because checking assignability to a union type involves checking assignability to each member and finding at least one match. I'm just noting that the IntelliSense type inspection behaves differently for union types from the way it behaves for, say, intersection types:

type U = A | B;
declare const u : U;
u.optional // (property) optional: string | undefined

type I = A & B;
declare const i : I;
i.optional // (property) optional?: string | undefined

It looks like a property present in both X and Y is optional in X & Y if and only if it's optional in both X and Y. I wonder if, when collapsing or mapping a union type, it should be the case that a property present in both X and Y is optional in X | Y if and only if it's optional in either X or Y. That should allow the following to be treated as homomorphic mapped type:

{[K in keyof (X | Y)]: (X | Y)[K]}

But backing up, what do you really want out of a mapped union type? For example:

type M = {a: number, b: string, c: boolean};
type N = {a: string, b: number, d: boolean};
type P = Pick<M | N,  'a' | 'b'>;
const whoops: P = {a: 123, b: 456}; // no error!

Do you like the current behavior of getting {a: string | number, b: string | number}, which is not assignable to M | N? Or would you prefer a union type like {a: number, b: string} | {a: string, b: number} which feels to me like the "morally correct" way of picking the a and b properties out of M | N?

If you are happier with the latter, then you can get that and the optional modifiers for free by using distributive conditional types:

type PickU<T, K extends keyof T> = T extends any ? {[P in K]: T[P]} : never;

That behaves just like Pick on non-union types, but you also get this:

type PickUnion = PickU<A | B, 'optional'>; 
let worksNow: PickUnion = {}; // no error

and this:

type P = PickU<M | N,  'a' | 'b'>;
const whoops: P = {a: 123, b: 456}; // error! number not assignable to string

Just a thought... if you replace your usages of Pick with PickU, do your problems go away?

@RomanHotsiy
Copy link
Author

if you replace your usages of Pick with PickU, do your problems go away?
@jcalz That's exactly what I needed. Thanks for bringing it up!

Closing the issue.

@eps1lon
Copy link
Contributor

eps1lon commented Feb 5, 2019

@jcalz The PickU definition is immensely helpful.

Could you explain why this is working differently from Pick? Just by "looking" at PickU and the standard library Pick I don't understand how they result in different behavior i.e. this

type Pick<T, K extends keyof T> = { [P in K]: T[P]; }; from lib.es5.d.ts
type PickU<T, K extends keyof T> = T extends any ? {[P in K]: T[P]} : never;

looks just like

type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
-type PickU<T, K extends keyof T> = T extends any ? {[P in K]: T[P]} : never;
+type PickU<T, K extends keyof T> = T extends any ? Pick<T, K> : never;

which should be equivalent if T extends any to

type Pick<T, K extends keyof T> = { [P in K]: T[P]; };
- type PickU<T, K extends keyof T> = T extends any ? Pick<T, K> : never;
+ type PickU<T extends any, K extends keyof T> = Pick<T, K>;

Why is that conditional changing the behavior of Pick?

@jcalz
Copy link
Contributor

jcalz commented Feb 5, 2019

@eps1lon The difference is that conditional types are distributive over unions when you use a bare type parameter before extends. It's an incredibly useful feature... with unintuitive syntax that makes it easy to miss.

@eps1lon
Copy link
Contributor

eps1lon commented Feb 5, 2019

@jcalz So PickU<M | N, Keys>; can be read as Pick<M, Keys> | Pick<N, Keys>? I'm interested in use cases for the standard library Pick (or any mapped type for that matter) that would break if the behavior of Pick would be replaced with PickU.

@jcalz
Copy link
Contributor

jcalz commented Feb 5, 2019

Yes. I can't think of something that would break if the definition of Pick were replaced with that of PickU but that doesn't mean there isn't one. I wonder if anyone has suggested or worked through the implications of having homomorphic mapped types automatically distribute over unions.

@eps1lon
Copy link
Contributor

eps1lon commented Feb 5, 2019

There is an open issue about this although it doesn't have much activity: #28339

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Domain: Mapped Types The issue relates to mapped types Needs Investigation This issue needs a team member to investigate its status.
Projects
None yet
Development

No branches or pull requests

4 participants