4

I would like to create a generic interface with properties that represent a union of properties from other interfaces.

Let's say I have two interfaces

interface A {
    something: string;
    somethingElse: number;
}

interface B {
    something: Array<string>;
}

I do not want to write interface C as

interface C {
    something: string | Array<string>;
    somethingElse?: number;
}

because that would mean that whenever I modify either of the interfaces A or B, I would need to manually modify interface C as well.

From what I've seen in the TypeScript documentation as well as answers here on Stack Overflow, I should declare a new type

type unionOfKeys = keyof A | keyof B;

and implement generic interface form

interface GenericInterface {
    <T>(arg: T): T;
}

I was thinking in the direction of

interface C {
    <T extends unionOfKeys>(arg: T): T extends unionOfKeys ? A[T] | B[T] : any
}

but that fails because of mismatch between a number of properties and their types.

I would appreciate any sort of help. Thank you.

3 Answers3

5

I think the following version of MergeUnion<T> might behave how you want:

type MergeUnion<T> = (
  keyof T extends infer K ? [K] extends [keyof T] ? Pick<T, K> & {
    [P in Exclude<(T extends any ? keyof T : never), K>]?:
    T extends Partial<Record<P, infer V>> ? V : never
  } : never : never
) extends infer U ? { [K in keyof U]: U[K] } : never;

type C = MergeUnion<A | B>;
// type C = { 
//  something: string | string[]; 
//  somethingElse?: number | undefined; }
// }

This is similar to the other answer in that it finds the union of all keys of all the constituents of T (call it UnionKeys, defined as T extends any ? keyof T : never) and returns a mapped type with all of them in it. The difference is that here we also find the intersection of all keys of all the constituents of T (call it IntersectKeys, defined as just keyof T) and split the keys T into two sets of keys. The one from the intersection are present in every constituent, so we can just do Pick<T, IntesectKeys> to get the common properties. The remainder, Exclude<UnionKeys, IntersectKeys> will be optional in the final type.

UPDATE 2019-08-23: the bug mentioned below seems to be fixed as of TS3.5.1

It's pretty ugly, and I'd clean it up if I felt better about it. The problem is that there's still an issue when any of the properties appearing in all constituents are themselves optional. There's a bug in TypeScript (as of TS3.5) where in {a?: string} | {a?: number}, the a property is seen as a required property like {a: string | number | undefined}, whereas it would be more correct to be treated as optional if any of the constituents have it as optional. That bug bleeds through to MergeUnion:

type Oops = MergeUnion<{a?: string} | {a?: number}>
// type Oops =  { a: string | number | undefined; }

I don't have a great answer there that isn't even more complicated, so I'll stop here.

Maybe this is sufficient for your needs. Or maybe @TitianCernicova-Dragomir's answer is sufficient for your needs. Hope these answers help you; good luck!

Link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This solves my problem entirely! Thank you so much for your time and effort, this is greatly appreciated. –  May 25 '19 at 09:59
3

Neither intersection types or union types will get us to C. A union type (A | B will only allow access to common properties). An intersection (A & B) will allow access to all properties but if the properties disagree between A and B the property will be an intersection of the two properties ( ex something will be string & Array<string>; which is not very useful here).

The solution is to build a custom mapped type that will take keys from all types passed in and create a union of property types from each member:

interface A {
    something: string;
    somethingElse: number;
}

interface B {
    something: Array<string>;
}

type KeyOf<T> = T extends any ? keyof T : never;
type PropValue<T, K extends PropertyKey> = T extends Record<K, infer V> ? V : never;
type Merge<T> = {
    [P in KeyOf<T>] : PropValue<T, P>
}

type C = Merge<A | B>
// type C = {
//     something: string | string[];
//     somethingElse: number;
// }

KeyOf will take a T and if T is a union it will return keys of all union members. It does this using the distributive property of conditional types

type K = KeyOf<{a : number} | { b: number }> //  "a" | "b". 

This is needed as keyof for a unuion will only return common members. (keyof ({a : number} | { b: number }) is never).

PropValuealso uses the distributive property of conditional types to extract a union of all value types for a key.

type V = PropValue<{a : number} | {a : string} |{ b: number }, 'a'> //  string | number

Putting it together in a mapped type we get Merge which maps over all keys in every member of the union and maps to a union of all possible property types.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • It looks like the OP wants a property to be optional in the output type if it's not present in all constituents of the input union. – jcalz May 24 '19 at 17:44
  • 1
    @jcalz hm .. that's true... if you have a better version I don't mind deleting mine.. I'm preparing for a presentation tomorrow and I'd better not procrastinate too much with this – Titian Cernicova-Dragomir May 24 '19 at 17:46
  • Hi, unfortunately as @jcalz has stated, the output type should be optional if it's not present in all constituents of the input union, hence why I accepted his answer as correct. I would love to thank you for your time and effort, because this explanation was on point, and I will definitely save it somewhere! Thank you! –  May 25 '19 at 09:59
1

Thanks, @jcalz, for the great answer! I've modified it for better readability if anyone's interested. Also, the mentioned bug is now solved.

type Keys<TUnion> =
  TUnion extends unknown ? keyof TUnion : never;

type Values<TObject extends Object> = {
  [TKey in keyof TObject]: TObject[TKey];
};

//

type RequiredKeys<TUnion> =
  keyof TUnion;

type RequiredValues<TUnion> =
  Pick<TUnion, RequiredKeys<TUnion>>;

//

type OptionalKeys<TUnion> =
  Exclude<Keys<TUnion>, RequiredKeys<TUnion>>;

type OptionalValue<TUnion, TKey extends PropertyKey> =
  TUnion extends Partial<Record<TKey, infer TValue>> ? TValue : never;

type OptionalValues<TUnion> = {
  [TOptionalKey in OptionalKeys<TUnion>]?: OptionalValue<TUnion, TOptionalKey>;
};

//

export type Merge<TUnion> = Values<
  RequiredValues<TUnion> &
  OptionalValues<TUnion>
>;


type Test = Merge<
  | { a?: string; b: string; c: number; }
  | { a?: number; b: string[]; c?: number; }
>;

// type Test = {
//   a?: string | number | undefined;
//   b: string | string[];
//   c?: number | undefined;
// };
Denis Zhbankov
  • 1,018
  • 1
  • 10
  • 10