I have a union type that represents a piece of data that a user can construct using form fields. The basic flow is that the user picks the kind of thing they want to make, then I present the proper UI, and as they edit form fields I update the stored object. The in-progress object is represented as a Partial-ized version of the union type, depending on the type of thing the user chose to make. There are two ways that I'd like to refer to the Partial-ized type, but both have problems.
The code snippet can explain more, but basically, the signature of the callback and the type guard are two ways I'd like to refer to the in-progress value. In both schemes I've come up with to define a Partial-ized union type, one of the two use-cases fails to compile.
It seems like variant one is more correct and precise, so I'm surprised that it fails to compile properly. I'd like to avoid casting if at all possible, in order to make this code as robust as possible to adding more members to the union type.
export interface Key {
type: "key";
key: string;
}
export interface KeyValue {
type: "key-value";
key: string;
value: string;
}
export type Either = Key | KeyValue;
export type Common = Pick<Either, "type" | "key">;
export const Common = {
toString: ({ type, key }: Common): string => null as any,
fromString: (s: string): Common => null as any,
};
// USE CASE 1: This does not work when using variant one, below.
const callback: (v: PartialEither) => void = null as any;
callback(Common.fromString(""));
// USE CASE 2: This does not work when using variant two, below.
// This makes sense, since variant two's definition drops the relationship between `type`
// and the corresponding object shape, so type narrowing can't work.
const either: PartialEither = null as any;
if (either.type === "key-value") {
either.value;
}
// VARIANT ONE
// Comment this out and replace it with variant two to see the errors change, above.
// Using this intermediate type so I can still rely on 'type' as the discriminant property of
// the PartialEither type.
type _PartialEither<T extends Either> = Pick<T, "type" | "key"> & Partial<Omit<T, "type" | "key">>;
export type PartialKey = _PartialEither<Key>;
export type PartialKeyValue = _PartialEither<KeyValue>;
export type PartialEither = PartialKey | PartialKeyValue;
// VARIANT TWO
// Uncomment this out replace variant one with it to see the errors change, above.
// type PartialEither = Pick<Either, "type" | "key"> & Partial<Omit<Either, "type" | "key">>
I don't have a great insight into why the compiler doesn't understand your PartialEither
type; it's not unprecedented that there be gaps like this involving intersections and mapped types, like microsoft/TypeScript#18538... not that this is exactly that issue. Anyway my intuition here is that whatever the problem is more like a bug/design limitation in TypeScript and less like a problem with your code. Not sure if there's an existing issue covering this or if you'd like to open one.
Still, if I wanted to proceed here, I'd try to turn PartialEither
into the simplest type I could... a union of object types with no intersections. One way to do this is with a utility type I call Expand
:
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
This will turn intersections of concrete object types into single object types (so {a: string} & {b: number}
will become {a: string; b: number}
) and also works on unions. It doesn't recurse down into the object properties; one can write an ExpandRecursive
that does that, but we don't need it.
Then we can do this:
export type PartialEither = Expand<PartialKey | PartialKeyValue>;
and see that the observed type of PartialEither
in IntelliSense is now:
type PartialEither = {
type: "key";
key: string;
} | {
type: "key-value";
key: string;
value?: string | undefined;
}
And when you do that your errors go away. That would be my suggestion.
Back to the "this seems like a bug or design limitation in TypeScript" topic:
Note that the compiler does think that PartialKey | PartialKeyValue
and Expand<PartialKey | PartialKeyValue>
are mutually assignable types, or the following would be an error:
type MutuallyAssignable<T extends U, U extends V, V = T> = true;
type Okay = MutuallyAssignable<PartialKey | PartialKeyValue, PartialEither>; // no error
But when it comes to assigning values of type Common
to them, the compiler is happy with one and upset with the other:
function hmm(common: Common) {
let nope: PartialKey | PartialKeyValue = common; // error
let yep: PartialEither = common; // okay
yep = nope; // okay also 🤔
}
So there's definitely something a bit screwy in the compiler's type analysis here. If I find out more (like an existing issue about this) I'll update; otherwise... good luck!
UPDATE: This may be related to microsoft/TypeScript#19927, not sure. I do see that your PartialEither
has Pick<Key, never>
in there, but I can't tell if it's the same issue or not 🤷