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