为什么 A | B允许两者结合,我该如何防止呢?

Why does A | B allow a combination of both, and how can I prevent it?

我很惊讶地发现 TypeScript 不会抱怨我做这样的事情:

type sth = { value: number, data: string } | { value: number, note: string };
const a: sth = { value: 7, data: 'test' };
const b: sth = { value: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };

我想也许 value 被选为类型联合判别式或其他东西,因为我唯一能想到的解释是如果 TypeScript 以某种方式理解 number 这里是例如 1 | 2 的超集。

所以我在第二个对象上将 value 更改为 value2

type sth = { value: number, data: string } | { value2: number, note: string };
const a: sth = { value: 7, data: 'test' };
const b: sth = { value2: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };

仍然,没有抱怨,我能够构建 c。尽管 IntelliSense 在 c 上出现故障,但当我进入 . 时它不会提示任何内容。如果我将 c 中的 value 更改为 value2.

,则相同

为什么这不会产生错误?显然,我没有提供一种或另一种类型,而是提供了两者的奇怪组合!

问题 Microsoft/TypeScript#14094 中的讨论与此处相关。

TypeScript 中的类型是 open,因为对象必须至少 类型描述的属性才能比赛。所以对象 { value: 7, data: 'test', note: 'hello' } 匹配类型 { value: number, data: string },即使它有多余的 note 属性。所以你的 c 变量确实是一个有效的 sth。如果 缺少 联合的某些组成部分所需的所有属性,它只会成为 sth

// error: missing both "data" and "note"
const oops: sth = { value: 7 };  

但是:当您在 TypeScript 中将新的对象文字分配给类型化变量时,它会执行 excess property checking to try to prevent errors. This has the effect of "closing" TypeScript's open types for the duration of that assignment. This works as you expect for interface types. But for unions, TypeScript currently (as mentioned in this comment) 仅抱怨未出现在 any[=59= 上的属性] 的组成部分。所以下面还是报错:

// error, "random" is not expected:
const alsoOops: sth = { value: 7, data: 'test', note: 'hello', random: 123 };

但 TypeScript 目前不会以您想要的严格方式对联合类型进行额外的 属性 检查,它会根据每个组成类型检查对象文字,并在所有类型中有额外的属性时抱怨他们。它确实用 discriminated unions, as mentioned in microsoft/TypeScript#12745 做到了这一点,但这并没有解决你的问题,因为 sth 的定义都没有被区分(意思是:有一个 属性 其文字类型恰好挑选出工会)。


因此,除非更改,否则最好的解决方法可能是在使用对象字面量时避免并集,方法是显式分配给预期的组成部分,然后在需要时扩大到并集:

type sthA = { value: number, data: string };
type sthB = { value: number, note: string };
type sth = sthA | sthB;

const a: sthA = { value: 7, data: 'test' };
const widenedA: sth = a;
const b: sthB = { value: 7, note: 'hello' };
const widenedB: sth = b;
const c: sthA = { value: 7, data: 'test', note: 'hello' }; // error as expected
const widenedC: sth = c; 
const cPrime: sthB = { value: 7, data: 'test', note: 'hello' }; // error as expected
const widenedCPrime: sth = cPrime; 

如果您真的想要表达一个独占对象类型的联合,您可以使用mapped and conditional类型来实现,通过将原始联合体变成一个新联合体,其中每个成员通过将它们添加为类型 never 的可选属性(显示为 undefined 因为可选属性始终可以是 undefined):

type AllKeys<T> = T extends unknown ? keyof T : never;
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type _ExclusifyUnion<T, K extends PropertyKey> =
    T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;

有了它,您可以将 sth“排除”到:

type xsth = ExclusifyUnion<sth>;
/* type xsth = {
    value: number;
    data: string;
    note?: undefined;
} | {
    value: number;
    note: string;
    data?: undefined;
} */

现在会出现预期的错误:

const z: xsth = { value: 7, data: 'test', note: 'hello' }; // error!
/* Type '{ value: number; data: string; note: string; }' is not assignable to
 type '{ value: number; data: string; note?: undefined; } | 
 { value: number; note: string; data?: undefined; }' */

Playground link to code

另一种选择是使用可选的 never 属性来明确禁止在联合中混合使用两种类型的字段:

type sth =
  { value: number, data: string; note?: never; } |
  { value: number, note: string; data?: never; };

const a: sth = { value: 7, data: 'test' };
const b: sth = { value: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };
   // ~ Type '{ value: number; data: string; note: string; }'
   //     is not assignable to type 'sth'.

ts-essentials 库有一个 XOR generic 可以用来帮助您构造独占联合,如下所示:

import { XOR } from 'ts-essentials';

type sth = XOR<
  { value: number, data: string; },
  { value: number, note: string; }
>;

const a: sth = { value: 7, data: 'test' };
const b: sth = { value: 7, note: 'hello' };
const c: sth = { value: 7, data: 'test', note: 'hello' };
// ~ Type '{ value: number; data: string; note: string; }'
//     is not assignable to type ...

这是最后一个例子的 playground link

这个答案解决了如何计算文字初始值设定项的验证,例如,{ value: 7, data: 'test', note: 'hello' } 到对象类型的联合,例如,type sth={ value: number, data: string } | { value: number, note: string },而不忽略任何未指定的多余属性。

此处显示的类型函数与中的ExclusifyUnion相当。然而,它不仅仅是使用相同输入但编码略有不同的另一种类型函数。相反,此处介绍的函数使用 附加输入 ,如下所述。

将文字初始值设定项的类型作为额外参数添加到类型函数中

考虑以下陈述:

type T1 = {<some props>}
type T2 = {<some props>}
type T3 = {<some props>}
type TU=T1|T2|T3
SomeTypeDef<T> = ...
const t:SomeTypeDef<TU> = {a:1,b:2}

最后一行是赋值语句。作业中发生的处理有两个不同且独立的部分:

  • 孤立的左侧,这是类型函数SomeTypeDef,具有单个输入变量TU
  • 正在确定 r.h.s 分配的有效性。文字初始值设定项 {<some props>} 到 l.h.s 类型。该计算使用无法更改的 Typescript 固定分配规则进行。

现在假设我们定义了一个额外的类型

type I = {a:1,b:2}

您会注意到 r.h.s 上的文字初始值设定项的类型。的任务。现在假设我们将该类型作为附加变量添加到 l.h.s.:

上的类型函数
const t:SomeTypeDefPlus<TU,I> = {a:1,b:2}

现在 l.h.s 类型函数有 其他 信息可以使用。所以SomeTypeDef<TU>能表达什么,SomeTypeDefPlus<TU,I>也能用同样的长度编码表达。然而 SomeTypeDefPlus<TU,I> 可能比 SomeTypeDef<TU> 表达更多的东西,and/or 可能能够用更短的代码表达同样的东西。在伪伪代码中:

Expressability(SomeTypeDefPlus<TU,I>) >= Expressability(SomeTypeDef<TU>)

你应该反对,因为

  • 写入类型type I = {<some props>},AND
  • 并编写 r.h.s 文字初始值设定项 .... = {<some props>}

是两倍的写作——代码长度的惩罚。这是真的。这个想法是——如果值得的话——最终将启用一种从 r.h.s 初始值设定项推断类型 I 的方法,例如预处理或新的打字稿语言功能。毕竟,静态信息 {<some props>} 就在那里,但由于设计技巧而无法访问,这有点愚蠢。

下面给出了代码演示,随后进行了讨论。

// c.f. https://github.com/microsoft/TypeScript/issues/42997
// craigphicks Feb 2021
//-----------------------
// TYPES
type T1 = {a:number,b:number}
type T2 = {a:number,c:number}
type T3 = {a:string,c?:number}
type T4 = {a:bigint, [key:string]:bigint}
type T5 = {a:string, d:T1|T2|T3|T4}
type T12 = T1|T2|T3|T4|T5
//-----------------------
// TYPES INFERRED FROM THE INITIALIZER 
type I0 = {}
type I1 = {a:1,b:1}
type I2 = {a:1,c:1}
type I3 = {a:1,b:1,c:1}
type I4 = {a:1}
type I5 = {a:'2',c:1}
type I6 = {a:'2'}
type I7 = {a:1n, 42:1n}
type I8 = {a:'1', d:{a:1n, 42:1n}}
type I9 = {a:'1', d:{}}
//-----------------------
// THE CODE 
type Select<T,I>= {[P in keyof I]: P extends keyof T ?
  (T[P] extends object ? ExclusifyUnionPlus<T[P],I[P]> : T[P]) : never} 
type ExclusifyUnionPlus<T,I>= T extends any ? (I extends Select<T,I> ? T : never):never
//-----------------------
// case specific type aliases
type DI<I>=ExclusifyUnionPlus<T12,I>
// special types for se question 
type sth = { value: number, data: string } | { value: number, note: string };
type DIsth<I>=ExclusifyUnionPlus<sth,I>
//-----------------------
// THE TESTS - ref=refuse, acc=accept
const sth0:DIsth<{ value: 7, data: 'test' }>={ value: 7, data: 'test' }; // should acc
const sth1:DIsth<{ value: 7, note: 'test' }>={ value: 7, note: 'test' }; // should acc
const sth2:DIsth<{ value: 7, data:'test', note: 'hello' }>={ value:7, data:'test',note:'hello' }; // should ref
type DI0=DI<I0> ; const d0:DI0={} // should ref
type DI1=DI<I1> ; const d1:DI1={a:1,b:1} // T1, should acc
type DI2=DI<I2> ; const d2:DI2={a:1,c:1} // T2, should acc
type DI3=DI<I3> ; const d3:DI3={a:1,b:1,c:1} // should ref
type DI4=DI<I4> ; const d4:DI4={a:1} // should ref
type DI5=DI<I5> ; const d5:DI5={a:'2',c:1}  // T3, should acc
type DI6=DI<I6> ; const d6:DI6={a:'2'}  // T3, should acc
type DI7=DI<I7> ; const d7:DI7={a:1n,42:1n}  // T4, should acc
type DI8=DI<I8> ; const d8:DI8={a:'1',d:{a:1n,42:1n}}  // T5, should acc
type DI9=DI<I9> ; const d9:DI9={a:'1',d:{}}  // should ref
//-------------------
// Comparison with type function NOT using type of intializer
// Code from SE  
type AllKeys<T> = T extends unknown ? keyof T : never;
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;
type _ExclusifyUnion<T, K extends PropertyKey> =
    T extends unknown ? Id<T & Partial<Record<Exclude<K, keyof T>, never>>> : never;
type ExclusifyUnion<T> = _ExclusifyUnion<T, AllKeys<T>>;
//-------------------
// case specific alias
type SU=ExclusifyUnion<T12>
// tests
const sd0:SU={} // should ref
const sd1:SU={a:1,b:1} // should acc
const sd2:SU={a:1,c:1} // should acc
const sd3:SU={a:1,b:1,c:1} // should ref
const sd4:SU={a:1} // should ref
const sd5:SU={a:'2',c:1}  // should acc
const sd6:SU={a:'2'}  // should acc
const sd7:SU={a:1n,42:1n}  // should acc
const sd8:SU={a:'1',d:{a:1n,42:1n}}  // should acc
const sd9:SU={a:'1',d:{}}  // should ref
// Apparently ExclusifyUnion doesn't handle addtional property speficier in T4
// Also does it handle deep objects?  Have posted message to ExclusifyUnion author, awaiting reply.

Typescript Playground

讨论

深度对象的代码递归 - ExclusifyUnionPlus<T,I> 调用 SelectSelect 然后当属性本身是对象时递归调用 ExclusifyUnionPlus<T[P],I[P]>

不包括一些边缘情况,例如成员函数。

测试

测试用例包括

  • 附加键
  • 深层对象(尽管只有 2 层)

结论

除了需要两次进入实例之外,所提出的范例(将初始化器类型添加到 lhs 函数)在几个检测多余属性的测试用例中被证明可以正常运行..

我们可以判断在l.h.s中加入初始化类型的实用价值。通过根据以下两个标准比较 ExclusifyUnionPlus 来键入函数:

  • 轻松清晰:
  • 总表达范围:

至于 'ease and clarity' ,ExclusifyUnionPlus 似乎更易于编码和理解。另一方面,两次编写初始化程序是不方便的。我已经提交 a proposal to Typescript issues 建议类似

const t:SomeTypeDefPlus<TU,I> = {a:1,b:2} as infer literal I

会有帮助。

至于'total range of expression',目前还不得而知。