从一个简单的联合类型看如何在TS中实现类型互斥

1,995 阅读7分钟

最近写 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 },为啥不报错?

百思不得其解,于是找有关人士咨询了下,这才发现原来还是个知识点

问题产生的原因

这个问题并不是只有我遇到了,网上早就有一些类似的问题,不过我翻看了很长时间,都是说如何解决这个问题,但对于问题产生的原因都没怎么说,唯一一个算是解释了原因的是在 TypeScriptGithub Issues 上,看 回答者的 Github 主页,应该是 TypeScript 的官方成员

1.png

我尝试理解下,在 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的小坑),问题在于 对象字面量的赋值,原理性的东西文章里面已经说得很清楚了,所以我就不再次赘述了,只解释一下上面两个例子的表现为什么不同

对于 代码1p2 变量的定义,就是通过 widen 消除了 { name:"n", firstName: "f", lastName: "l" } 这个 fresh object literal typefreshness,即 p2 不是一个 fresh object literal type,那么即使 p2p1 多出 firstNamelastName 这两个属性,也不认为 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

虽然 AB 在字面量上具有完全相同的结构体属性,但编译器并不认为它们是相同的类型,所以 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 propertyTS 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 这种方式显式断言类型都是可以按照预期工作的

优点是直观易懂,缺点也很明显,就是必须要加一个额外的标识属性

参考文档