最近写 ts
的时候,遇到一个场景,需要为一个函数的入参加个类型,这个入参是个对象,其值的类型要么是 { a: number }
, 要么是 { b: number }
,略一思考,我就写下如下代码
function fn1(data: { a: number } | { b: number }) {}
fn1({ a: 2 })
fn1({ b: 3 })
复制代码
写完之后觉得问题不大,然而忽然发现如下传参也是可以通过的
fn1({ a: 2, b: 3 })
复制代码
这就有点懵了,{ a: 2, b: 3 }
这个类型既不是 { a: number }
也不是 { b: number }
,为啥不报错?
百思不得其解,于是找有关人士咨询了下,这才发现原来还是个知识点
问题产生的原因
这个问题并不是只有我遇到了,网上早就有一些类似的问题,不过我翻看了很长时间,都是说如何解决这个问题,但对于问题产生的原因都没怎么说,唯一一个算是解释了原因的是在 TypeScript
的 Github Issues 上,看 回答者的 Github 主页,应该是 TypeScript
的官方成员
我尝试理解下,在 TS
中不存在精确类型(最终类型),所有的类型都是可扩充的,所以对于以下代码,是可以通过检查的:
// 代码 1
var p1: { name: string };
var p2 = { name:"n", firstName: "f", lastName: "l" };
p1 = p2; // OK
复制代码
但是呢,你如果像下面这样写就不可以了
// 代码 2
// Object literal may only specify known properties, and 'another' does not exist in type "{ name: string; }"
var p: { name: string } = { name: "n", another: "f" }; // Error
复制代码
至于为什么不可以,他也做了解释,但我反复看了半天也没看明白他到底解释了啥,然后我继续找,终于找到了原理性的解释(Typescript关于fresh object literal type的小坑),问题在于 对象字面量的赋值,原理性的东西文章里面已经说得很清楚了,所以我就不再次赘述了,只解释一下上面两个例子的表现为什么不同
对于 代码1,p2
变量的定义,就是通过 widen
消除了 { name:"n", firstName: "f", lastName: "l" }
这个 fresh object literal type
的 freshness
,即 p2
不是一个 fresh object literal type
,那么即使 p2
比 p1
多出 firstName
、lastName
这两个属性,也不认为 p2
相对于 p1
存在 excess properties
,所以 p2
可以赋值给 p1
而 代码2 之所以不行,就是因为对象字面量 { name: "n", another: "f" }
的类型是 fresh object literal type
,而它又没有通过 widen
或者 assertion
消除 freshness
,那么 TS
在做 赋值兼容性检测
的时候,发现 { name: "n", another: "f" }
相对于 p
存在 excess properties
(即 another
),所以 { name: "n", another: "f" }
不能赋值给 p
看完之后我陷入沉思,似乎是又学到了一个知识点,但是……这个理论还是无法解释文章开头的那段代码啊!
不过我在 Typescript关于fresh object literal type的小坑 里看到了一句话:
Typescript
实际存在着两种兼容性,子类型兼容性(subtype compatibility
)和赋值兼容性(assignment compatibility
)
文章里主要解释了 赋值兼容性(assignment compatibility
),但因为文章出发点的原因对于子类型兼容性(subtype compatibility
) 没怎么提,但我看着这几个字感觉很像是突破点,所以当做关键字搜了下,发现了 结构化类型(Structual Typing) 这个东西
很多强类型语言例如 Go
采用的是 Nominal Type System(标明类型系统)
,例如,对于如下 Go
代码:
type A struct {
Name string
}
type B struct {
Name string
}
a := A{
Name: "zhangsan",
}
b := B{
Name: "zhangsan",
}
a = b // Error cannot use b (variable of type B) as A value in assignment
复制代码
虽然 A
和 B
在字面量上具有完全相同的结构体属性,但编译器并不认为它们是相同的类型,所以 b
无法赋值给 a
但在 TS
中类似的赋值就可以
type A = {
name: string;
}
type B = {
name: string;
}
let a: A = {
name: 'zhangsan'
}
let b: B = {
name: 'zhangsan'
}
a = b // OK
复制代码
这是因为 TS
采用的是 结构化类型(Structual Typing),在某种意义上,可以称之为是 鸭子类型(Duck Typing
)
一个类型代表一个集合,类型这个集合的元素是属性,如果一个类型A
是类型B
的子集,则有:
const b: B
const a: A = b
复制代码
但这是有前提的,a
不能直接等于一个 B
类型的字面量,必须是要通过 widen
或者 assertion
消除了 freshness
fn1({ a: 2, b: 3 })
之所以能够通过检查,就是因为 { a: number }
或者 { b: number }
是 { a: 2, b: 3 }
的子集,并且 { a: 2, b: 3 }
中的属性都是 known property
(即都在 { a: number } | { b: number }
这个 type
已知范围内),所以认为 { a: 2, b: 3 }
的类型是 { a: number } | { b: number }
,是可以接受的
但如果你写成 fn1({ a: 2, b: 3, c: 4 })
那就不行了,因为 c
这个 property
不在 { a: number } | { b: number }
内,所以是 unknown property
(TS has is flagging "unknown" properties. This only applies to object literals, that happen to have a contextual type
),那么就可以通过消除 freshness
来使其通过检查
function fn1(data: { a: number } | { b: number }) {}
const data = { a: 2, b: 3, c: 4 }
fn1(data) // OK
fn1({ a: 2, b: 3, c: 4 } as { a: number } | { b: number }) // OK
复制代码
这里还有一个点需要注意下,{ a: number } | { b: number }
这个类型是一个联合类型,它的意思并不是 { a: number }
或者 { b: number }
,并不是指得两个类型的或
关系
联合类型就是联合类型,{ a: number } | { b: number }
是一个整体的类型,所以你不能说 c
这个 property
不在 { a: number }
或 { b: number }
这两个类型内,所以 { a: 2, b: 3, c: 4 }
不能赋给 { a: number } | { b: number }
也不能说因为 a
是 { a: number }
的属性,b
是 { b: number }
的属性,所以 { a: 2, b: 3 }
可以赋给 { a: number } | { b: number }
{ a: number } | { b: number }
是一个整体的联合类型,不能分开看
解决方案
原因找到了,但我就是想让 fn1
要么接受 { a: number }
要么接受 { b: number }
,不能是其他类型也不能是 { a: 2, b: 3 }
这种父类型,该怎么做呢?
多余类型可选覆盖
这个方法在 Github Issues 的帖中已经有人提出过了,已经被抽离成一个 npm 类型库 了
主代码就几行
type Without<T, U> = {
[P in Exclude<keyof T, keyof U>]?: never
}
type XOR<T, U> = (T | U) extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U
复制代码
也很容易使用
interface A {
a: string
}
interface B {
b: string
}
let A_XOR_B: XOR<A, B>
A_XOR_B = { a: '' } // OK
A_XOR_B = { b: '' } // OK
A_XOR_B = { a: '', b: '' } // fails
A_XOR_B = {} // fails
复制代码
原理也不复杂,就是通过将其余属性设为可选并且值是 undefined
例如,参数只接受 { a: number }
或者 { b: number }
,为了防止你传入 { a: number; b: number }
,我直接将类型 { a: number } | { b: number }
改成 { a: number; b?: undefined } | { b: number; a?: undefined }
,这样你只能传 { a: number }
、 { b: number }
、{ a: undefined; b: number }
、{ a: number; b: undefined }
这四个具体的类型了,{ a: number; b: number }
显然是不行的
Discriminated union
借助 Discriminated unions,解决类型的不确定性
即可以通过一个额外的标识属性来限制类型只能是具体的哪几个
function fn1(data: { kind: 'a'; a: number } | { kind: 'b'; b: number }) {}
fn1({ kind: 'a', a: 2 })
fn1({ kind: 'b', b: 3 })
fn1({ kind: 'a', a: 2, b: 3 }) // Error
fn1({ kind: 'b', a: 2, b: 3 }) // Error
复制代码
优点是直观易懂,缺点也很明显,就是必须要加一个额外的标识属性