1

Let's say I have two types defined like this:

const type A = { identifier: string, properties: { p1: string, p2: string }};
const type B = { identifier: string, properties: { p3: string, p4: string }};

I want to define a type being an union of A and B, and where 'properties' key would be partially filled (but always present, i.e. at least one key should be present). However, I do not want to mix between keys of 'properties' object of these two types.

For example, these instances would be valid:

{ identifier: 'id', properties: { p1: 'prop1', p2: 'prop2' }}
{ identifier: 'id', properties: { p2: 'prop2' }}
{ identifier: 'id', properties: { p3: 'prop3', p4: 'prop4' }}
{ identifier: 'id', properties: { p3: 'prop3' }}

But these instances would NOT be valid:

{ identifier: 'id', properties: {}} // 'properties' is empty
{ identifier: 'id' } // 'properties' is missing
{ identifier: 'id', properties: { p1: 'prop1', p3: 'prop3' }} // 'properties' has keys from A and B

I tried a lot of different configurations but nothing seems to work. Also, I didn't find any question answering this issue.

tspart0519
  • 13
  • 2
  • Is `const` a typo here? – jcalz May 19 '21 at 21:27
  • Is [this](https://tsplay.dev/wee9Ew) what you're looking for? If not, could you elaborate on what's wrong with it? Good luck! – jcalz May 19 '21 at 21:38
  • @jcalz you should post that code as an answer, it's far more elegant than what [I came up with](https://tsplay.dev/WGnJvm) – Ming Slogar May 19 '21 at 21:43
  • The requirement to have `properties` be a partial object but have at least one key is highly unusual and is the main thing throwing a wrench into this, otherwise it would be much more simple... – jered May 19 '21 at 21:48

1 Answers1

1

This looks like a combination of the following two questions:


"How to create a Partial-like that requires a single property to be set" make a type function AtLeastOneProp<T> which takes turns an object type T into a Partial<T>-like type but it does not allow all properties to be missing:

type AtLeastOneProp<T extends object> = T extends object ? { [K in keyof T]-?:
  Pick<T, K> & Partial<T> extends infer O ? { [P in keyof O]: O[P] } : never }[keyof T]
  : never;

Here I am using distributive conditional types, conditional type inference, and various utility and mapped types to iterate over each property K of T and produce a version of T where only that property is required (Pick<T, K>) and the rest are optional (Partial<T>), and produce a union of those.

If we do this to, for example, the properties property of A, we get:

type AtLeastOnePropFromAProperties = AtLeastOneProp<A["properties"]>;
/* type AtLeastOnePropFromAProperties = {
    p1: string;
    p2?: string | undefined;
} | {
    p2: string;
    p1?: string | undefined;
} */

You can see that either p1 is required and p2 is not, or p2 is required and p1 is not.


"TypeScript a | b allows combination of both" make a type function ExclusifyUnion<T> which turns a union of object types T into an exclusive union of such types where each union member rejects properties belonging to the other members:

type AllKeys<T> = T extends any ? keyof T : never;
type ExclusifyUnion<T, K extends AllKeys<T> = AllKeys<T>> =
  T extends any ? (T & Partial<Record<Exclude<K, keyof T>, never>>) extends
  infer O ? { [P in keyof O]: O[P] } : never : never;

This is also using distributive conditional types and utility types. AllKeys<T> is a union of all the keys from all the union members of T, and ExclusifyUnion<T> takes every union member of T and adds Partial<Record<Exclude<K, keyof T>, never>>, where K is AllKeys<T> from the full union. Exclude<K, keyof T> looks at just those keys from the full union which are not present in the current union member, and Partial<Record<..., never>> makes an object type whose values at those keys must be missing (or undefined).

If we use that on the union of properties from A and B, we get:

type ExclusifyAorBProperties = ExclusifyUnion<A["properties"] | B["properties"]>
/* type ExclusifyAorBProperties = {
    p1: string;
    p2: string;
    p3?: undefined;
    p4?: undefined;
} | {
    p3: string;
    p4: string;
    p1?: undefined;
    p2?: undefined;
} */

You can see that either p1 and p2 are required while p3 and p4 are prohibited, or p3 and p4 are required while p1 and p2 are prohibited.


For your desired type, we will combine AtLeastOneProp<T> and ExclusifyUnion<T> with your A and B types:

type MyType = {
  identifier: string,
  properties: ExclusifyUnion<AtLeastOneProp<A["properties"] | B["properties"]>>
}

If you inspect properties with IntelliSense you will see how this works, at least until the type is truncated:

/* (property) properties: {
    p1: string;
    p2?: string | undefined;
    p3?: undefined;
    p4?: undefined;
} | {
    p2: string;
    p1?: string | undefined;
    p3?: undefined;
    p4?: undefined;
} | {
    p3: string;
    p4?: string | undefined;
    p1?: undefined;
    p2?: undefined;
} | {
    ...;
} */

Let's test it out on your use cases:

const a: MyType = { identifier: 'id', properties: { p1: 'prop1', p2: 'prop2' } }; // ok
const b: MyType = { identifier: 'id', properties: { p2: 'prop2' } }; // ok
const c: MyType = { identifier: 'id', properties: { p3: 'prop3', p4: 'prop4' } }; // ok
const d: MyType = { identifier: 'id', properties: { p3: 'prop3' } }; // ok

const e: MyType = { identifier: 'id', properties: {} } // error!
// Type '{}' is not assignable -----> ~~~~~~~~~~
const f: MyType = { identifier: 'id' } // error!
//    ~ <-- Property 'properties' is missing 
const g: MyType = { identifier: 'id', properties: { p1: 'prop1', p3: 'prop3' } } // error!
// ---------------------------------> ~~~~~~~~~~
// Type '{ p1: string; p3: string; } is not assignable

Looks good!

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360