1

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 nevers 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

GreenTea222
  • 195
  • 3
  • 11
  • Why is this called "deep"? Is it supposed to be recursive? – jcalz Jul 31 '22 at 18:27
  • I also don't quite get why it's called "intersection", since the actual "deep intersection" of the members of `Input` looks to me like `{message: string, type: never, a: number, b: number}`. By what standard are we supposed to remove a shared property like `type`? If I answer this I would call it something like `Collapse` or `Combine` or something that doesn't have misleading connotations. – jcalz Jul 31 '22 at 18:29
  • @jcalz Thank you for reviewing my question. I added 'Edit' section that hopefully answers your questions. – GreenTea222 Jul 31 '22 at 19:46
  • You seem to think that a type like `{a: string}` has an `undefined` or `never` property at, say, the key `b`. That’s not true. It’s more like an `unknown` property. Object types in typescript are not “sealed” or “exact”; values of object types are allowed to have extra properties not mentioned in the type declaration. Hence `{a: string} & {b: number}` is equivalent to `{a:string; b:number}` and not `{}` or `{a: never, b: never}` or `{a: undefined, b: undefined}`. So you are not really asking for an intersection. – jcalz Jul 31 '22 at 20:11
  • Before I even consider a recursive solution (and that's probably out of scope anyway) I want to understand if I've implemented what you're asking for. [This approach](https://tsplay.dev/NByvDw) produces `{message: string}` for `Input`, but there are probably so many edge cases that it would be nice to see more examples before I bother writing up an answer. Could you test it? – jcalz Jul 31 '22 at 21:17
  • @jcalz "values of object types are allowed to have extra properties not mentioned in the type declaration" even though in many cases it's a blessing, I need this type to prevent this in a situation where it could introduce run time errors. I've used your `Combine` type [here in context](https://stackoverflow.com/q/73186407/11545965) (edit at the bottom), maybe that will explain better where I'm going with it. – GreenTea222 Jul 31 '22 at 21:53
  • My point there is that what you are asking for is not simply an intersection of properties, and so the name `DeepIntersection` is misleading (hence the name `Combine`). Anyway, can I take it that `Combine` does what you want and I should write up an answer here? – jcalz Jul 31 '22 at 21:54
  • @jcalz As you mentioned, maybe there will be some edgecases, but I haven't found any yet. This type is able to add type errors in cases when TS doesn't add, but definitely should (as explained in that other thread). So yes, "can I take it that Combine does what you want and I should write up an answer here", thank you a lot, I does exactly what I wanted it to do : ) – GreenTea222 Jul 31 '22 at 22:00

1 Answers1

1

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. (e.g., 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 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 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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you for your detailed explanation, I'll try to play with this code to see when there could be some edge cases. – GreenTea222 Aug 01 '22 at 18:31