最近写 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 } 这种父类型,该怎么做呢?
函数重载
最简单的就是函数重载
function fn1({ a: number }): void
function fn1({ b: number }): void
function fn1(x) {}
fn1({ a: 1 }) // ok
fn1({ b: 1 }) // ok
fn1({ a: 1, b: 1 }) // Error
但是如果我消除了 freshness,函数重载就 hold不住了
const data = { a: 1, b: 2 }
fn1(data) // ok
而在实际项目中,不太可能直接传入一个对象字面量的,最大的可能是传入一个变量,也就是消除了 freshness的,所以不太靠得住
多余类型可选覆盖
这个方法在 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 } 显然是不行的
function fn3(data: XOR<{ a: number }, { b: number }>) {}
fn3({ a: 1 }) // ok
fn3({ b: 1 }) // ok
fn3(data as { a: number } | { b: number }) // ok
fn3({ a: 1, b: 2 }) // Error
const data = { a: 1, b: 2 }
fn3(data) // Error
可以看到,只要你不是通过 as 这种方式显式断言类型(你都主动 as 来想着绕过类型检查了,那就没办法了),都是可以 hold住的
Discriminated union
借助 Discriminated unions,解决类型的不确定性
即可以通过一个额外的标识属性来限制类型只能是具体的哪几个
function fn2(data: { kind: 'a'; a: number } | { kind: 'b'; b: number }) {}
fn2({ kind: 'a', a: 2 })
fn2({ kind: 'b', b: 3 })
fn2({ kind: 'b', a: 2, b: 3 }) // Error
fn2({ kind: 'b', a: 2, b: 3 } as { kind: 'a'; a: number } | { kind: 'b'; b: number }) // OK
const y = { kind: 'a', a: 2, b: 3 }
fn2(y) // Error
这种和上面的类似,只要你不是通过 as 这种方式显式断言类型都是可以按照预期工作的
优点是直观易懂,缺点也很明显,就是必须要加一个额外的标识属性