打字稿:将类型定义为类型与部分存在的对象的联合

Typescript: Define type as union of types with a partially present object

假设我有两个这样定义的类型:

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

我想将类型定义为 A 和 B 的联合,其中 'properties' 键将被部分填充(但始终存在,即至少应存在一个键)。但是,我不想混合使用这两种类型的 'properties' 对象的键。

例如,这些实例是有效的:

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

但这些实例无效:

{ 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

我尝试了很多不同的配置,但似乎没有任何效果。另外,我没有找到任何问题来回答这个问题。

这看起来像是以下两个问题的组合:


"" make a type function AtLeastOneProp<T> which takes turns an object type T into a Partial<T>-like 类型但它不允许所有属性都丢失:

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;

这里我使用 distributive conditional types, conditional type inference, and various utility and mapped types 遍历 T 的每个 属性 K 并生成 T 的版本,其中只有 属性 是必需的 (Pick<T, K>),其余的是可选的 (Partial<T>),并生成它们的并集。

如果我们这样做,例如 Aproperties 属性,我们得到:

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

您可以看到要么需要 p1p2 不需要,要么需要 p2p1 不需要。


"" make a type function ExclusifyUnion<T> which turns a union 对象类型 T 转换为此类类型的 独占 联合,其中每个联合成员拒绝属于其他成员的属性:

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;

这也是使用分布式条件类型和实用类型。 AllKeys<T>T 的所有联合成员的所有键的联合,ExclusifyUnion<T> 获取 T 的每个联合成员并添加 Partial<Record<Exclude<K, keyof T>, never>>,其中 K 是来自完整联合的 AllKeys<T>Exclude<K, keyof T> 仅查看当前联合成员中 的完整联合中的那些键,并且 Partial<Record<..., never>> 生成一个对象类型,其在这些键上的值必须失踪(或undefined)。

如果我们在 ABproperties 的并集上使用它,我们得到:

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;
} */

你可以看到p1p2是必需的,而p3p4是禁止的,或者p3p4是必需的,而 p1p2 是禁止的。


对于您想要的类型,我们会将 AtLeastOneProp<T>ExclusifyUnion<T> 与您的 AB 类型组合:

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

如果您使用 IntelliSense 检查 properties,您将看到它是如何工作的,至少在类型被截断之前是这样:

/* (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;
} | {
    ...;
} */

让我们在您的用例中对其进行测试:

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

看起来不错!

Playground link to code