7

I have a discriminated union, for example:

type Union = { a: "foo", b: string, c: number } | {a: "bar", b: boolean }

I need to derive a type that includes all potential properties, assigned with types that may be found on any member of Union, even if only defined on some - in my example:

type CollapsedUnion = { 
  a: "foo" | "bar", 
  b: string | boolean, 
  c: number | undefined 
}

How can I make a generic that derives such collapsed unions?
I need a generic that supports unions of any size.

Similar behaviour can be achieved as a byproduct by using native Omit utility, but unfortunately for me it leaves out properties that are not present on every union memeber (does not count them in as undefined or via ?).

Michal Kurz
  • 1,592
  • 13
  • 41
  • 1
    Michal In case you haven't seen it, I've updated my answer with a solution that solves your problem :) – Aron Jan 29 '21 at 11:08

3 Answers3

10

I found a two way(s)!

EDIT: this is a solution with two separate type parameters. See lower down for a solution with a single union type parameter.

// The source types
type A = { a: "foo", b: string, c: number }
type B = { a: "bar", b: boolean }

// This utility lets T be indexed by any (string) key
type Indexify<T> = T & { [str: string]: undefined; }

// Where the magic happens ✨
type AllFields<T, R> = { [K in keyof (T & R) & string]: Indexify<T | R>[K] }

type Result = AllFields<A, B>
/**
 * 
 * type Result = {
 *   a: "foo" | "bar";
 *   b: string | boolean;
 *   c: number | undefined;
 * }
 */

How it works

AllFields is a mapped type. The 'key' part of the mapped type

[K in keyof (T & R) & string]

means that K extends the keys of the union T & R, which means it will be a union of all the keys that are either in T or in R. That's the first step. It ensures that we are making an object with all the required keys.

The & string is necessary as it specifies that K also has to be a string. Which is almost always going to be the case anyway, as all object keys in JS are strings (even numbers) – except for symbols, but those are a different kettle of fish anyway.

The type expression

Indexify<T | R>

returns the union type of T and R but with string indexes added in. This means that TS won't throw an error if we try to index it by K even when K doesn't exist in one of T or R.

And finally

Indexify<T | R>[K]

means that we are indexing this union-with-undefineds-for-string-indexes by K. Which, if K is a key of either T, R, or both, will result in that key's value type(s).

Otherwise, it will fall back to the [string]: undefined index and result in a value of undefined.

Here's a playground link


EDIT: solution for a single generic parameter

You specified that you don't actually want this to work for two type parameters, but with an existing union type, regardless of how many members are in the union.

It took blood, sweat and tears but I've got it.

// Magic as far as I'm concerned.
// Taken from https://stackoverflow.com/a/50375286/3229534
type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

// This utility lets T be indexed by any key
type Indexify<T> = T & { [str: string]: undefined; }

// To make a type where all values are undefined, so that in AllUnionKeys<T>
// TS doesn't remove the keys whose values are incompatible, e.g. string & number
type UndefinedVals<T> = { [K in keyof T]: undefined }

// This returns a union of all keys present across all members of the union T
type AllUnionKeys<T> = keyof UnionToIntersection<UndefinedVals<T>>

// Where the (rest of the) magic happens ✨
type AllFields<T> = { [K in AllUnionKeys<T> & string]: Indexify<T>[K] }


// The source types
type A = { a: "foo", b: string, c: number }
type B = { a: "bar", b: boolean; }

type Union = A | B

type Result = AllFields<Union>
/**
 * 
 * type Result = {
 *   a: "foo" | "bar";
 *   b: string | boolean;
 *   c: number | undefined;
 * }
 */

I got UnionToIntersection from a brilliant answer by @jcalz. I've tried to understand it but can't. Regardless, we can treat it as a magic box that transforms union types into intersection types. That's all we need to get the result we want.

New TS playground link

Aron
  • 8,696
  • 6
  • 33
  • 59
  • Hey Aron, a very nice answer, but unfortunately not exactly what I need - I need a generic that works with an union of any size. I spent around 45 minutes trying to modify your answer to make it work that way, but failed - I edited my question. Would you care to try to help me a little more, please? :) – Michal Kurz Jan 28 '21 at 20:39
  • @MichalKurz when you say 'union of any size' what exactly do you mean? You mean more than 2 unions? or unions of types with more than 3 members? I can't make sense of what your goal is from your comment/TS play example – Aron Jan 28 '21 at 20:43
  • I mean an union that has an arbitrary number of members - so I need my generic to take an already-made union. In your solution, `AllFields` doesn't really take an union, but two interfaces as separate parameters. – Michal Kurz Jan 28 '21 at 20:57
  • 1
    @MichalKurz I have edited my answer and, I believe, solved your real question. – Aron Jan 28 '21 at 22:00
  • 1
    @Aron nice! It is better than my answer as it does not require to construct artificial `ProtoUnion` type. – artur grzesiak Jan 29 '21 at 11:04
  • Thanks @arturgrzesiak :) also thanks for fixing my link! – Aron Jan 29 '21 at 11:07
  • 1
    @Aron This is exactly what I was looking for. I also tried this approach myself yesterday, but didn't find my way around `UnionToIntersection` returning `never`. `UndefinedVals` utility works like a charm and tought me a new idea about creating intermediary types if deriving from them directly doesn't work for me. Thank you so much! – Michal Kurz Jan 29 '21 at 13:29
  • 1
    My pleasure! It was quite the challenge but I'm glad I was able to help you – Aron Jan 29 '21 at 14:30
1

It is doable; one possible solution is presented below. I would be interested if it is possible to achieve it in a simpler way. I added comments to walk you through the code.

// an axiliary type -- we need to postpone creating a proper union, as a tuple type can be traversed recursively
// I added additional branch to make the task a bit harder / to make sure it works in a more generic case
type ProtoUnion = [{ a: "foo", b: string, c: number }, {a: "bar", b: boolean }, { c: string }]

// an axiliary type to recover proper Union
type CollapseToUnion<T extends Record<string, any>[], Acc = {}> = // starting with a tuple of records and accumulator
  T extends [infer H, ...infer Rest] ? // traverse
    Rest extends Record<string, any>[] ? // if still a record present
      CollapseToUnion<Rest, (H | Acc)> : // recursive call: collapse as union
        // in other cases return accumulator 
        Acc : 
          Acc

// union recovered
type Union = CollapseToUnion<ProtoUnion>

// this type is empty, so starting with union is _impossible_ to recover all needed keys in a generic way 
type UnionKeys = keyof Union 

// this type achieves what you are asking for but only for 2 types
type MergeAsValuesUnion<A, B> = { [K in (keyof A | keyof B)]: 
    K extends keyof A ? 
      K extends keyof B ? A[K] | B[K] : 
        A[K] | undefined :
          K extends keyof B ? B[K] | undefined :
            never
  }

type OriginalUnionIntersected = MergeAsValuesUnion<ProtoUnion[0], ProtoUnion[1]>
/*
type OriginalUnionIntersected = {
    a: "foo" | "bar";
    b: string | boolean;
    c: number | undefined;
}
*/


// this is works exactly the same as CollapseToUnion, but instead of reducing with | 
// it uses MergeAsValuesUnion to reduce
type CollapseToIntersetion<T extends Record<string, any>[], Acc = {}> = T extends [infer H, ...infer Rest] ?
  Rest extends Record<string, any>[] ?
    CollapseToIntersetion<Rest, MergeAsValuesUnion<H, Acc>> 
    : Acc : Acc


const i: CollapseToIntersetion<ProtoUnion> = {
  a: 'bar', // "bar" | "foo" | undefined
  b: true, // string | boolean | undefined
  c: undefined // string | number | undefined
}

EDIT:

CollapseToIntersetion was bit off. Starting with {} as a default accumulator results in having | undefined in value types.

// this is works exactly the same as CollapseToUnion, 
// but instead of reducing with | -- it uses MergeAsValuesUnion to reduce; 
// Acc = T[0] since Acc = {} would result in all values types unioned with undefined
type CollapseToIntersetion<T extends Record<string, any>[], Acc = T[0]> = T extends [infer H, ...infer Rest] ?
  Rest extends Record<string, any>[] ?
    CollapseToIntersetion<Rest, MergeAsValuesUnion<H, Acc>> 
    : Acc : Acc

PLAYGROUND

artur grzesiak
  • 20,230
  • 5
  • 46
  • 56
  • Hey Artur, thanks a lot for your answer! It goes way above my head, but is very close to what I need - there's just one thing: if I start with `type ProtoUnion = [{ a: "foo", b: string, c: number }, {a: "bar", b: boolean }]`, I still get `{ a : "bar" | "foo" | undefined, b: string | boolean | undefined, c: number | undefined }`, even though `a` and `b` are always defined - is there a way to get rid of the `undefined`s in those cases? – Michal Kurz Jan 28 '21 at 21:01
  • 1
    @MichalKurz you are right I missed that - updated the answer and playground link. Thank you for a very nice question. – artur grzesiak Jan 29 '21 at 10:29
  • 2
    @arturgrzesiak This is it! I will end up using Aron's solution, because my it's so much easier for me to follow. But thank you very much for your kindness, time and effort :) – Michal Kurz Jan 29 '21 at 13:36
  • 2
    @MichalKurz this is exactly the type of questions I am looking for on SO and as wrote in the answer I was curious if a simpler solution exists. I as well, do think Aron's solution is better. Moreover I need some time to wrap my head with the trick Aron used to preserve the keys of the union. So thank you as well ;) – artur grzesiak Jan 29 '21 at 14:00
0

This solution based on Aron's answer deep-collapses unions recursively, not just at the top level:

export type ExtractObjects<T> = Extract<T, Record<keyof any, any>>
export type ExcludeObjects<T> = Exclude<T, Record<keyof any, any>>

export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never

export type Indexify<T> = T & { [str: string]: undefined }
export type AllUnionKeys<T> = keyof UnionToIntersection<{ [K in keyof T]: undefined }>
// https://stackoverflow.com/questions/65750673/collapsing-a-discriminated-union-derive-an-umbrella-type-with-all-possible-key
export type CollapseUnionOfOnlyObjects<T extends Record<keyof any, any>> = {
  [K in AllUnionKeys<T> & string]: Indexify<T>[K]
}

type ExtractAndCollapseObjects<T> = CollapseUnionOfOnlyObjects<ExtractObjects<T>>

// recursive union collapse
export type CollapseUnion<T> = ExtractObjects<T> extends never
  ? T
  :
      | {
          [K in keyof ExtractAndCollapseObjects<T>]: CollapseUnion<ExtractAndCollapseObjects<T>[K]>
        }
      | ExcludeObjects<T>
Michal Kurz
  • 1,592
  • 13
  • 41