【问题标题】:Why does A | B allow a combination of both, and how can I prevent it?为什么 A | B允许两者结合,我该如何防止呢?
【发布时间】:2021-11-26 23:58:26
【问题描述】:

我很惊讶地发现 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,则相同。

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

【问题讨论】:

  • 我不明白,您希望代码中的哪条语句导致错误?一切似乎都还好。

标签: typescript xor typescript-types


【解决方案1】:

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

TypeScript 中的类型是打开在某种意义上,一个对象必须有至少类型所描述的属性,以供匹配。所以对象 { 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 以尝试防止错误。这具有在该分配期间“关闭”TypeScript 的开放类型的效果。这与您对接口类型的预期一样有效。但是对于联合,TypeScript 目前(如this comment 中所述)仅抱怨未出现的属性任何的组成部分。所以下面仍然是一个错误:

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

但是 TypeScript 目前并没有按照您想要的严格方式对联合类型进行额外的属性检查,它会针对每个组成类型检查对象文字,如果所有这些类型中都有额外的属性,就会抱怨。正如microsoft/TypeScript#12745 中提到的,它确实使用discriminated unions 执行此操作,但这并没有解决您的问题,因为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; 

如果你真的想表达一个独家的对象类型的联合,您可以使用 mappedconditional 类型来执行此操作,方法是将原始联合转换为新联合,其中每个成员通过将它们添加为可选属性来明确禁止联合其他成员的额外键输入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

【讨论】:

  • 有时,打字稿似乎有一个秘密积分系统,就像它的工作方式一样。受歧视的工会(尤其是部分分离的工会)很容易落入这些陷阱。
  • 我正在将您的 ExclusifyingUnion 函数与类似的函数 in an answer below 进行比较。当类型中包含附加键说明符时,我遇到了一些问题。那应该发生吗?另外,ExclusifyUnion 是否处理深层对象?我看不到递归调用 - ExclusifyUnion 中是否有必要这样做?
  • 对不起,我不知道我是否完全理解你在做什么。据推测,您将“排他联合”操作与“验证但不扩大”操作混合在一起,但我不明白这个问题与后者有何关系。此答案中的 ExclusifyUnion 类型函数并不意味着递归地应用于属性(问题中没有问到),也不会一定如果它所操作的对象类型具有 index signatures,则做有用的事情(同样,这里不问)。
  • 我在下面发布的解决方案正在回答这个问题 - “什么通用类型函数(使用分布式条件类型等)将计算通用'ExclusiveUnion'()'? (我更喜欢 OR-Union 来表示“满足一个或多个工会成员”)。 “一般”包括“索引签名”和具有可能是联合属性的深层对象。 --- 我提出了这个问题 here 但它已关闭,我被指示在这里发帖。错误的问题发布到?
  • 如果你愿意,你可以在这里发帖,尽管如果你在回答的开头就解释你试图满足的用例会有所帮助,因为问题不是直接询问它们。然后那些有类似问题但发现我的答案不够充分的人会看到你的类似这样的话:“如果你正在尝试针对此处其他答案未解决的 [用例列表] 执行此操作,请继续阅读:”。
【解决方案2】:

另一种选择是使用可选的 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

【讨论】:

  • FWIW,这个答案和我的一样。 ExclusifyUnion&lt;A | B&gt;XOR&lt;A, B&gt; 都为联合中的“关闭”键添加了可选的never 属性。
【解决方案3】:

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

这里介绍的类型函数相当于above solution of @jcalz中的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. 分配的有效性。文字初始值设定项 {&lt;some props&gt;} 为 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&lt;TU&gt;可以表达什么,SomeTypeDefPlus&lt;TU,I&gt;也可以用同样的长度编码表达。然而,SomeTypeDefPlus&lt;TU,I&gt; 可能比 SomeTypeDef&lt;TU&gt; 表达更多的东西,和/或可能能够用更短的代码表达相同的东西。在伪伪代码中:

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

你应该反对,因为

  • 写入类型type I = {&lt;some props&gt;},并且
  • 并编写 r.h.s 文字初始值设定项.... = {&lt;some props&gt;}

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

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

// 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 https://stackoverflow.com/q/46370222/4376643
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  https://stackoverflow.com/a/46370791/4376643
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&lt;T,I&gt; 调用 SelectSelect 然后当属性本身是对象时递归调用 ExclusifyUnionPlus&lt;T[P],I[P]&gt;

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

测试

测试用例包括

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

结论

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

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

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

至于“简单明了”,ExclusifyUnionPlus 似乎更易于编码和理解。另一方面,两次编写初始化程序是不方便的。我已经提交了a proposal to Typescript issues建议类似的东西

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

会有帮助的。

至于“总表达范围”,目前还不得而知。

【讨论】:

  • 您能否在这里阐明“实例类型”一词的用法? AFAIK,它专门指构造函数的实例(也许你的意思是初始化?)
  • T4 类型导致原始 ExclusifyUnion 由于索引签名而失败,实际上,但是,坦率地说,我有点不知所措为什么要这样做。 Off-note:我想知道,你从哪里找到我名字的如此奇特的转录? :)
  • @OlegValter ExclusifyUnion 使用子函数AllKeys', which *should* be the union of all keys over all objects, e.g., 'a'|'b'。但是,当其中一个对象包含索引签名 [key:string]:&lt;&gt; 时,它支配 AllKeys 值并且该值变为 string | number。你问为什么number包括在内?那是打字稿。然后是联合的任何对象 X 的异或不是包含索引签名[key:string]:&lt;&gt; 变为X &amp; { [key:string]:undefined, {key:number]:undefined},实际上是never
  • 我明白在哪里这失败了,我不明白的是,坦率地说,为什么会出现错误状态Property 'd' is missing in type but required in type 'T5'。似乎所有成员都检查了可分配性,失败了,然后最后一个 T5 用于最终检查,导致缺少属性。具有 never 类型的索引签名不会阻止分配已知属性,例如:type t = { [ x: number ] : never; a: 5 }; const t:t = { a: 5 }; //OK
猜你喜欢
  • 2018-03-04
  • 1970-01-01
  • 2012-11-29
  • 2022-11-24
  • 1970-01-01
相关资源
最近更新 更多