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
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.
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
@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
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?
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.
@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?
@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.
@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.
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.
There is an open issue about this although it doesn't have much activity: #28339
Most helpful comment
@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.