为什么 A | B 允许同时存在,如何防止它?

34

我很惊讶地发现 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

我认为可能会把 value 作为类型联合辨识符之类的东西挑出来,因为我能想到的唯一解释就是 TypeScript 在这里以某种方式理解 number1 | 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中输入.时,它不会提供任何建议。如果我将c中的value更改为value2,同样也是如此。

为什么这不会产生错误?显然,我未能提供其中一种类型,而是提供了一种奇怪的混合体!


我不明白,你的代码中哪个语句会导致错误?看起来都没问题啊。 - Nitzan Tomer
3个回答

33

这里与 Microsoft/TypeScript#14094 讨论的内容相关。

TypeScript 中的类型是“开放”的,这意味着对象必须至少具有类型描述的属性才能匹配。因此,对象 { value: 7, data: 'test', note: 'hello' } 匹配类型 { value: number, data: string },即使它具有多余的属性 note

const obj = { value: 7, data: 'test', note: 'hello' };
const val: sthA = obj; // okay

所以你的c变量确实是一个有效的sth。只有当它缺少某个联合成员所需的所有属性时,才无法成为sth

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

然而:当你在TypeScript中为一个类型变量分配一个新的对象字面量时,它会执行过度属性检查以尝试防止错误。这将"关闭"TypeScript在该赋值期间的开放类型。对于接口类型,它按照你的预期工作。但对于联合类型,TypeScript目前(如此评论所述)只会抱怨不出现在任何组成部分上的属性。因此,以下仍然是一个错误:
// error, "random" is not expected:
const alsoOops: sth = { value: 7, data: 'test', note: 'hello', random: 123 };

但是 TypeScript 目前在联合类型上并不会以你想要的严格方式进行多余属性检查,即它不会将对象字面量与每个成分类型进行比较,并在所有成分类型中存在额外属性时报错。正如 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; 

如果您真的想表达对象类型的独占联合,您可以使用mappedconditional类型来实现,将原始联合转换为一个新联合,在其中每个成员都通过将其作为never类型的可选属性添加到联合中明确禁止其他成员的额外键(因为可选属性总是可以为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; }' */


游乐场链接到代码


2
有时候似乎 TypeScript 有一个秘密的得分系统来控制它的工作方式。使用区分联合类型(尤其是部分不相交的类型)很容易陷入这些陷阱中。 - Simon_Weaver
我正在将你的 ExclusifyingUnion 函数与下面一个可比较的函数进行比较:https://dev59.com/71YO5IYBdhLWcg3wF9zK#66407294。当附加键规定包含在类型中时,我遇到了一些问题。这种情况是否应该发生?此外,ExclusifyUnion 是否可以处理深层对象?我没有看到递归调用 - 在 ExclusifyUnion 中是否需要递归调用? - Craig Hicks
1
抱歉,我不确定我是否完全理解您在答案中所做的事情。您可能正在将“排他联合”操作与“验证但不扩大”操作混合在一起,但我不明白这个问题与后者有什么关系。此答案中的ExclusifyUnion类型函数并不意味着要递归应用于属性(这不是问题中提出的),如果它所操作的对象类型具有索引签名(同样不是在这里提问),它也不一定会执行有用的操作。 - jcalz
我不太理解这行代码的作用:type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;。 如果 T 是一个接口 interface { f(){}; a:number; },那么 T 将不会扩展 {a:number}Id<T> 将会评估为 never。那么这是目的吗?充当过滤器? - Craig Hicks
当我使用这个解决方案时,确实会在添加来自其他类型的属性时出现错误,但它仍然显示为 vscode 智能感知选项中的属性 :(. 只是使用 OP 中的普通旧解决方案具有正确的属性智能感知提示,但然后不会出错。希望有一个整体的解决方案。 - papiro
显示剩余4条评论

21

另一种选项是使用可选的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通用类型,例如:

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 ...

以下是最后一个示例的播放链接


1
就此问题而言,这个答案和我的做法是一样的。ExclusifyUnion<A | B>XOR<A, B> 都会为联合类型中“关闭”的键添加可选的 never 属性。 - jcalz

1

本答案讨论如何计算将文字初始化器(例如{ value: 7, data: 'test', note: 'hello' })分配给对象类型的联合,例如type sth={ value: number, data: string } | { value: number, note: string },同时不忽略任何未指定的多余属性。

这里介绍的类型函数类似于@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.文字初始化程序{<some props>}赋给l.h.s类型的有效性。该计算使用Typescript的固定赋值规则进行,无法更改。

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

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

你会注意到这是赋值右侧字面量初始化程序的类型。现在假设我们将该类型作为附加变量添加到左侧的类型函数中:

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

现在左侧类型的函数有了额外的信息可以使用。因此,无论 SomeTypeDef<TU> 可以表达什么,SomeTypeDefPlus<TU,I> 都可以用相同长度的代码来表达。然而 SomeTypeDefPlus<TU,I> 可能会表达比 SomeTypeDef<TU> 更多的东西,并且/或者能够用更短的代码来表达相同的东西。伪伪代码如下:

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

你可能会反对,因为

  • 写类型type I = {<some props>},以及
  • 写右侧的字面值初始化程序.... = {<some props>}

这是两倍的代码长度惩罚。没错,如果值得的话,一种方法最终将使能够从右侧的初始化程序中推断出类型I,例如预处理或新的TypeScript语言特性等。毕竟,静态信息{<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 https://dev59.com/71YO5IYBdhLWcg3wF9zK
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://dev59.com/71YO5IYBdhLWcg3wF9zK#46370791
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

TypeScript Playground

讨论

代码递归处理深层对象 - ExclusifyUnionPlus<T,I> 调用 Select,然后当属性本身是对象时,Select 会递归调用 ExclusifyUnionPlus<T[P],I[P]>

一些边缘情况未包含在内,例如成员函数。

测试

测试用例包括:

  • 额外的键
  • 深层对象(只有两级)

结论

除了需要两次输入实例的要求之外,所提出的范式(将初始化器类型添加到左手边的函数中)已被证明可以正确地处理检测多余属性的几个测试用例。

我们可以通过比较 ExclusifyUnionExclusifyUnionPlus 来评估将初始化器类型添加到左手边的类型函数的实际价值,根据以下两个标准:

  • 易用性和清晰度:
  • 表达范围的总体大小:
就“易用性和清晰度”而言,ExclusifyUnionPlus似乎更容易编写和理解。另一方面,两次编写初始化程序比较麻烦。我已经提交了一个关于TypeScript问题的建议,认为应该采取类似的措施。
const t:SomeTypeDefPlus<TU,I> = {a:1,b:2} as infer literal I

会很有帮助。

至于“表达的总范围”,目前还不清楚。


请问您能否澄清这里“实例类型”一词的用法?据我所知,它仅指构造函数的实例(也许您是指初始化?) - Oleg Valter is with Ukraine
“T4”类型会导致原始的“ExclusifyUnion”失败,因为存在索引签名,但是说实话,我有点不知道为什么会这样。顺便说一句:我想知道你在哪里找到了这样一个奇特的我的名字的转录? :) - Oleg Valter is with Ukraine
@OlegValter ExclusifyUnion使用子函数AllKeys',它*应该*是所有对象上所有键的并集,例如,'a'|'b'。然而,当其中一个对象包含索引签名[key:string]:<>时,它会支配AllKeys值,并且该值变为string | number。你问为什么包括number?那是因为TypeScript。然后,对于联合中不包含索引签名[key:string]:<>的任何对象X的异或运算变为X & {[key:string]:undefined, {key:number]:undefined},这实际上是never`。 - Craig Hicks
我确实理解这个失败的原因,但我不明白的是为什么会导致错误状态 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 - Oleg Valter is with Ukraine

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接