简体   繁体   中英

TypeScript deep intersection of objects union

Issue

I would like to implement DeepIntersection type that does following:

type Input =
    | { message: string | number; type: "a"; a: number }
    | { message: string | boolean; type: "b"; b: number };

type DeepIntersection<T> = /* TO IMPLEMENT */ T;

type Output = DeepIntersection<Input>;
// => { message: string; type: never; a: never; b: never };
// which then can be easly converted to => { message: string }

I know how to produce these:

// With Pick<T, keyof T>
type AlmostThereOutput1 = { message: string | number | boolean; type: "a" | "b" };

// With https://stackoverflow.com/a/47375979/11545965 but '&' instead of '|'
type AlmostThereOutput2 =
    | { message: string | number; type: "a" }
    | { message: string | boolean; type: "b" };

but unfortunatly I ran out of ideas how to produce what I actually want.

Context

Let's say that we have an array of objects of various types (possibly a discrimination union), and we want to implement a function that can update any item by it. This function could accept:

  • callback (prevItem: Item) => Item (react useState style)
  • data commonProps: Partial<DeepIntersection<Item>> that can be safely injected into any existing item as such {...prevItem, ...commonProps} .

EDIT - Additional explanation:

I called it DeepIntersection because intersection operator & works like this for non-object types:

type Intersection = 'a' & ('b' | 'a');
// => 'a'

This is exactly the behavior that I need but applied deeper for every property.

So in my example it would be:

type Message = (string | number) & (string | boolean); // string
type Type = "a" & "b"; // never
type A = number & undefined; // never
type B = undefined & number; // never

Result with never s is perfectly fine too:

type Result = { message: string; type: never; a: never; b: never };

since removing never properties is quite easy.

Currently I didn't think about a recursive solution, but if it's possible to implement I'd love to see both. I think about using this helper to possibly solve this: TypeScript allows unsafe Partial usage in generic

Here is one possible approach:

type Combine<T, K extends keyof T = keyof T> = { [P in K]:
    (T extends unknown ? (x: T[P]) => void : never) extends
    ((x: infer I) => void) ? I : never
} extends infer O ?
    { [K in keyof O as O[K] extends never ? never : K]: O[K] } : never

Here's how it works. First, I need to treat T as a single union-typed thing by itself, in order to get the keys shared by all members of the union. The keyof type operator is contravariant in its operand (see this q/a for a description of variance), so if T is a union, then keyof T is an intersection of the keys. (eg, keyof (A | B | C) is equivalent to (keyof A) & (keyof B) & (keyof C) .) This I pre-compute as K so we can make a mapped type iterating over just those shared keys, as {[P in K]: ...} . In the case of Input , that's just message and type .

Second, I also need to treat T as a union over which we distribute a type operation. I want to be able to split T into its union members and do stuff with those members. And that is why I've got (T extends unknown? ... : never) . In fact the whole section (T extends unknown? (x: T[P]) => void: never) extends ((x: infer I) => void)? I: never (T extends unknown? (x: T[P]) => void: never) extends ((x: infer I) => void)? I: never uses the UnionToIntersection<T> technique as in this q/a to convert the union of properties at each shared key of T into an intersection. So for message this would be (string | number) & (string | boolean) , and for type this would be "a" & "b" .

Finally, we want to eliminate any properties whose intersections reduce to never . That's what extends infer O? { [K in keyof O as O[K] extends never? never: K]: O[K] }: never extends infer O? { [K in keyof O as O[K] extends never? never: K]: O[K] }: never extends infer O? { [K in keyof O as O[K] extends never? never: K]: O[K] }: never does. It takes the mapped type, copies it to a new type parameter O , and then uses key remapping to filter out any properties whose value type is never .

Let's test it on Input :

type Input =
    | { message: string | number; type: "a"; a: number }
    | { message: string | boolean; type: "b"; b: number };

type Output = Combine<Input>;
/* type Output = {
    message: string;
} */

Looks good!


Note that this only works one level deep (it won't {a: {b: 0 | 1}} | {a: {b: 1 | 2}} into {a: {b: 1}} , for example), and there are probably lots of other edge cases where the above Combine<T> implementation does something different from what you might want. Hopefully you can use this as a starting point. But of course, this sort of type manipulation is very tricky; it's not obvious how and when the compiler decides to treat a union type as a single cohesive type, when it splits it into pieces and forms the result back into a union, and when it splits it into pieces and forms the result back into an intersection. Seemingly minor alterations will switch from one behavior to another. These behaviors are (mostly) documented, but it's easy to get wrong. So be warned!

Playground link to code

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM