TypeScript。意外的交叉点
发表于2021年7月21日
作者:@ddprrt
阅读时间:13分钟
更多关于TypeScript,JavaScript
有时在编写TypeScript时,你通常在JavaScript中做的一些事情工作起来有些不同,会引起一些奇怪的、令人费解的情况。有时你只是想给一个对象属性赋值,却得到一个奇怪的错误,比如*"类型'字符串|数字'不能赋值给类型'从不'。Type 'string' is not assignable to type 'never'. (2322)"*
不要担心,这并不是什么不正常的事情,只是 "意外的交叉类型 "让你对类型系统多了一些思考。
索引访问类型和赋值#
让我们来看看这个例子。
let person = { name: "Stefan", age: 39}type Person = typeof person;let anotherPerson: Person = { name: "Not Stefan", age: 20};function update(key: keyof Person) { person[key] = anotherPerson[key]; // 💥}update("age");
我们创建一个小函数,让我们通过提供一个键来更新一个对象anotherPerson 到对象person 。person 和anotherPerson 都有相同的类型Person ,但是 TypeScript 向我们抛出了错误2322。类型'string | number'不能被分配给类型'never'。Type 'string' is not assignable to type 'never'.。
那么,这是怎么回事呢?
对于TypeScript来说,通过索引访问操作符进行的属性赋值是非常难追踪的。即使你通过keyof Person 缩小了所有可能的访问键,可能被分配的值也是string 或 number (分别为姓名和年龄)。如果你在语句的右边有一个索引访问(读),这是好的,但如果你在语句的左边有一个索引访问(写),这就有点意思了。
TypeScript不能保证你传递的值确实是正确的。看看这个函数签名。
function update_ambiguous(key: keyof Person, value: Person[keyof Person]) { //...}update_ambiguous("age", "Stefan");
没有什么能阻止我在每个键上添加一个错误的类型值。除了TypeScript,它向我们抛出了一个错误。但为什么TypeScript要告诉我们类型是never呢?
为了允许一些赋值,TypeScript做出了妥协。TypeScript不允许在右手边有任何赋值,而是寻找可能值的最小公分母。以此为例。
type Switch = { address: number, on: 0 | 1}declare const switcher: Switch;declare const key: keyof Switch;
这里,两个键都是number 的子集。 那么,地址是整个数字集,另一边是0或1。绝对有可能给这两个字段都设置0或1!而这也是你用TypeScript得到的东西。
switcher[key] = 1; //👍switcher[key] = 2; //💥 Nope!
TypeScript通过对所有属性类型进行交集类型来获得可能的可分配值。这意味着,在Switch 的情况下,它是number & (0 | 1) ,归结为0 | 1 。在所有Person 属性的情况下,它是string & number ,没有重叠,因此它是never!哈!这就是罪魁祸首!
那么你能做什么呢?
绕过这种严格性的一个方法(这是为你自己好!)是使用泛型。我们不允许所有的keyof Person 值被访问,而是将 keyof Person 的一个特定子集绑定到一个泛型变量。
function update<K extends keyof Person>(key: K) { person[key] = anotherPerson[key]; // 👍}update("age");
当我做update("age") 时,K 被绑定到"age" 的字面类型上。这里没有歧义!
有一个理论上的漏洞,因为我们可以用一个更广泛的通用值来实例化update 。
update<"age" | "name">("age")
但这是TypeScript团队所允许的......目前来说。也请看Anders Hejlsberg的评论。请注意,Anders要求看到这种情况下的用例,这完美地说明了TypeScript团队的工作方式。在右侧通过索引访问的原始赋值有很多潜在的错误,他们给了你足够的保障,直到你非常有意地做你想做的事情。这是在排除整类错误,而不至于太碍事。
含糊不清的函数#
还有一种情况,你会遇到意外的交叉类型。以这个奇妙的判别的联合类型为例。
type Singular = { value: string, validate: (val: string) => boolean, kind: "singular"}type Multiple = { value: string[], validate: (val: string[]) => boolean, kind: "multiple"}type Props = Singular | Multiple
很好。一些非常相似的类型,用一个漂亮的字面类型来创造一个区别。但是当我们开始在一个函数中使用这个时,事情突然中断了。
function validate({ validate, value, kind }: Props) { if (kind === "singular") { validate(value); // 💥 Oh no! }}
TypeScript向我们抛出的错误与之前的错误类似,我们得到错误2345:"string | string[]"类型的参数不能分配给 "string & string[]"类型的参数。
好吧,那么交叉类型 string & string[] ,从何而来?问题在于我们对输入参数的解构。当我们把validate 、value 和kind 从我们的Props 中解构出来时,它们就失去了与原始类型的联系。突然间,我们有三个不同的类型要处理。
kind的类型"singular" | "multiple"value类型的string | string[]validate的类型(val: string) => boolean | (val: string[]) => boolean
同样,与原始类型Props 没有联系。所以在我们检查"singular" 的那一刻,我们没有跳到类型系统的另一个分支。这意味着,在我们调用validate 的时候,TypeScript 认为它可以是两个函数类型中的任何一个。它试图通过创建一个所有函数的所有参数的交集类型来创建所有可能的函数类型的最低公分母。
因此,为了使该函数在类型上安全地工作,你必须传入一个类型为string & string[] 的值。这又是非常罕见的,实际上是不可能发生的,有人会说这永远不可能发生。
那么你能做什么呢?
答案很简单。不要破坏结构。在这种情况下,保持原来的类型关系不变要容易得多。
function validate(props: Props) { if(props.kind === "singular") { props.validate(props.value); }}
TypeScript现在清楚地知道在哪里进行分支,以及你的对象的属性得到什么类型。
令人震惊的最后。一个组合
它可以变得更难 😱
让我们看看下面这个结构。
type FormFields = { age: { value: number, validator: (val: number) => boolean }, name: { value: string, validator: (val: string) => boolean }}
你可能已经知道我在说什么了。如果我想通过索引访问(一个键)来访问某个属性,然后用相关的值来调用函数。让我们用到目前为止所学到的所有东西来试试吧。
function validate<K extends keyof FormFields>(key: K, forms: FormFields) { forms[key].validator(forms[key].value) // 💥 TS2345}
不,不可以!不可以。即使我们把 key 绑定到一个特定的值上,而且我们没有对参数进行结构化处理,我们也没有可能运行这个。问题是,两个索引访问都是读操作。这意味着TypeScript只是为每个属性创建一个联合类型。
forms[key].validatoris of type(val: number) => boolean | (val: string) => booleanforms[key].value是类型的number | string
这意味着TypeScript试图将所有可能的number | string 的值调用到一个相交的函数类型:(val: number & string) => boolean 。number & string 又是永远不会,如果你想知道的话。
而这是非常难以克服的事情。因为当我们对forms 进行索引访问时,我们得到的只是联合类型。为了使其工作,我们需要将forms[key].validator 改为(val: number | string ) => boolean 。而这需要一个小的过程。
首先,让我们创建一个通用类型,代表我们的字段。这在后面会很方便。
type Field<T> = { value: T, validator: (val: T) => T}type FormFields = { age: Field<number>, name: Field<string>}
有了这个Field<T> 类型,我们就可以创建一个验证函数,做它应该做的事情。
function validate_field<T>(obj: Field<T>) { return obj.validator(obj.value);}
到目前为止,还不错。有了这个,我们已经可以做类似的验证了
validate_field(forms.age);
一旦我们进行索引访问,我们仍然有一个小问题。
function validate<K extends keyof FormFields>(key: K, forms: FormFields) { let obj = forms[key]; validate_field(obj); // 💥 TS2345}
同样的问题。但是,既然我们知道得更多,我们就可以帮助TypeScript的类型系统往正确的方向推一下。
function validate<K extends keyof FormFields>(key: K, forms: FormFields) { let obj = forms[key]; validate_field(obj as Field<typeof obj.value>); }
吁。虽然我们通常不希望有类型断言,但这个断言是完全有效的。我们把TypeScript指向我们联合类型中的一个特定分支,并把它缩小到一个明确的子集。通过typeof obj.value 和Field 的结构方式,没有任何歧义,我们知道,这是对的。其余的工作由奇妙的类型安全的函数接口来完成。
作为一种选择,我们可以对obj做一个明确的类型注解,我允许一个更广泛的类型,包括所有可能的值。
function validate<K extends keyof FormFields>(key: K, forms: FormFields) { let obj: Field<any> = forms[key]; validate_field(obj); }
随你喜欢。你有更多的想法吗?请让我知道!
底线#
TypeScript有一个独特而特殊的任务,那就是将一个类型系统附加到一个非常灵活的语言上。TypeScript试图在这样做的时候尽可能的健全。这意味着对于某些任务来说,它变得非常严格,并排除了那些没有明显问题的情况和语句。而每当我们遇到这样的情况,就有办法与类型系统讨论什么是正确的,什么是不正确的。这就是独特之处,也是渐进式类型系统的力量所在。
如果你想阅读更多,我强烈推荐这一期,它详细介绍了改进索引访问类型的合理性的理由。还有几个玩法供你参考
非常感谢Ty和Pezi给我的一些脑筋急转弯。这很有趣,我希望你能像我一样得到很多启发!"。
我已经写了一本关于TypeScript的书!查看《TypeScript的50堂课》,由Smashing杂志出版。
TL/DR🏃♀️🚶♀️庆祝🎉🎉