从 zustand 的 ts 声明讲起,了解 ts 中的逆变和协变

写在前面

太长不看👉 关于逆变和协变,可以看最后的总结

文中的例子可以在在 typescript playground 验证

让我们开始吧!

在 zustand 官网文档中,在 ts 中使用例子如下:

typescript 复制代码
import { create } from 'zustand'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

哦吼,非常简洁的代码!但是为什么我的 store 需要自行声明一下 BearState 类型,ts 范型不能自动推断出 store 里的类型么?比如在 redux 中,是这样写的:

php 复制代码
const bearStore = createSlice({
  initialState: {
    bears: 0,
  },
  reducers: {
    increase: (state) => {
      state.bears += 1 // bears: number
    }
  }
})

bears 的类型被自动推断了!

为什么 zustand 不能自动推断呢?在 zustand 的文档中,我找到了如下说明:

Because state generic T is invariant . As long as the generic to be inferred is invariant (i.e. both covariant and contravariant), TypeScript will be unable to infer it.

因为 zustand 的 state 的范型 T 是不变的(invariant),只要推断的泛型是不变的(即协变和逆变),TypeScript 就无法推断它。

如果你不了解相关概念,这段话会给人一种:

我每个字儿都认识,但是就不知道组合起来啥意思的感觉。 🤯

实际上,ts 团队似乎也有意不让开发者接触这些晦涩的概念,其官网中很少提到过逆变和协变相关的概念。

让我们从最基础的例子开始一步步理解什么是逆变和协变,以及他们在 zustand 声明中的应用吧!

最基础的例子

我们将用到以下几个类:

ts 复制代码
class Person {}

class Designer extends Person {
    design() {}
}

class Engineer extends Person {
    code() {}
}

class TSEngineer extends Engineer {
    codeTS() {}
}

如上图所示,Person 是 Engineer 和 Designer 的父类型,同时 Engineer 是 TSEngineer 的父类型。

考虑以下代码:

ts 复制代码
declare let engineer: Engineer
declare let tsEngineer: TSEngineer
declare let designer: Designer

engineer = tsEngineer // OK
tsEngineer = engineer // Error!
engineer = designer // Error!

tsEngineer 是 engineer 子类型,所以 tsEngineer 可以赋值给 engineer,反之则会报错。同样,designer 赋值给 engineer 也会报错。

以上代码很好理解,我们不细讲,让我们把范型的概念加进来!

协变

考虑有如下 getter 方法的声明:

ts 复制代码
type Getter<T> = () => T

对于以下的代码,哪一行是正确的,哪一行会报错呢?

ts 复制代码
declare let engineerGetter: Getter<Engineer>
declare let tsEngineerGetter: Getter<TSEngineer>

engineerGetter = tsEngineerGetter // ?
tsEngineerGetter = engineerGetter // ?

答案揭晓:

tsEngineerGetter 可以赋值给 engineerGetter,engineerGetter 不能赋值给 tsEngineerGetter。 第一行能通过编译,第二行会报错。

这里将涉及到我们讲的第一个概念:协变(covariance)

为什么第二行代码编译不通过?

假设第二行代码编译通过,我们将 engineerGetter 赋值给 tsEngineerGetter,执行 tsEngineerGetter 后将会得到类型为 TSEngineer 的对象 x,但由于 tsEngineerGetter 是由 engineerGetter 赋值而来,对象 x 可能实际上并没有 codeTS 方法,如果调用 x.codeTS() 可能会导致运行时报错。所以我们认为这一行代码是不安全的。

反之第一行代码是安全的。

从中我们总结一下,协变的规则和上面提到的基础例子中的规则是一样的:

如果 AB 的子类型,并且 F<A> 也是 F<B> 的子类型,那么类型 F 就是在该参数上协变的。

上面的 getter 函数中,范型 T 是作为函数返回值返回的,我们可以总结出另一条规则:

在 ts 函数中,返回值类型是协变的。

逆变

考虑有如下 setter 方法的声明:

ts 复制代码
type Setter<T> = (arg: T) => void

对于以下的代码,哪一行是正确的,哪一行会报错呢?

ts 复制代码
declare let engineerSetter: Setter<Engineer>
declare let tsEngineerSetter: Setter<TSEngineer>

engineerSetter = tsEngineerSetter // ?
tsEngineerSetter = engineerSetter // ?

答案揭晓:

与上一小节相反,第一行代码会报错,第二行不会,你答对了吗? 简单举个例子:

ts 复制代码
tsEngineerSetter = (p) => {
  p.codeTS() // 执行到这里会报错!
  //...
}
const engineerSetter: Setter<Engineer> = tsEngineerSetter
engineerSetter(new Engineer())

如果以上代码能编译通过,tsEngineerSetter 被赋值给了 engineerSetter,engineerSetter 按照类型需要传入一个 Engineer 对象,而 Engineer 对象上没有 codeTS 方法,会导致运行时报错。

这是我们遇到的第二种场景:逆变(contravariance)

从中我们总结一下,逆变的规则和协变正好相反:

如果 AB 的子类型,并且 F<B>F<A> 的子类型,那么类型 F 就是在该参数上逆变的。

上面的 setter 函数中,范型 T 是作为函数的参数,我们可以总结出另一条规则:

在 ts 函数中,参数类型是逆变的。

双变

等等,在上面的结论中,我们说:在 ts 函数中,参数类型是逆变的。

有的同学会举手说🙋:老师,我看有的地方说:在 ts 函数中,参数类型是双变的。

什么是双变(bivariance)

双变是指即可以是逆变也可以是协变,允许类型 A 和类型 B 之间的双向替换。

在 ts 中,参数类型默认是双变的,允许更宽泛的函数参数传值,但也带来了一些不安全性,在 ts 2.7 之后,引入了 strictFunctionType 配置,在该配置下,参数类型是逆变的。

在 zustand 代码中,你可能看到过这种声明:

ts 复制代码
 type SetStateInternal<T> = {
   _(
     partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
     replace?: boolean | undefined,
   ): void
 }['_']

这里的语法相当晦涩 🤯

我们写一个简化版:

ts 复制代码
type SetStateInternal<T> = {
  _(partial: T): void
}['_']

看起来是一个常规的函数声明,这里的 _ 有什么用吗?🤔

我们是不是可以写成这样?

ts 复制代码
type SetStateInternal<T> = (partial: T) => void

当然可以!除了一个细小的区别,第二种方式类似于我们上面提到的逆变的场景,SetStateInternal<T> 满足逆变的规则,参数类型是逆变的。而第一种方式会绕过严格模式下的参数类型检查,将参数类型变为双变的(是的,即使开启了严格模式也不会变为逆变!)。

ts 复制代码
type SetStateInternal<T> = {
  _(partial: T): void
}['_']

declare let testEngineer: SetStateInternal<Engineer>
declare let testTSEngineer: SetStateInternal<TSEngineer>

testEngineer = testTSEngineer // OK
testTSEngineer = testEngineer // OK

type SetStateInternal2<T> = (partial: T) => void

declare let test2Engineer: SetStateInternal2<Engineer>
declare let test2TSEngineer: SetStateInternal2<TSEngineer>

test2Engineer = test2TSEngineer // Error!
test2TSEngineer = test2Engineer // OK

在 zustand 代码中,需要更宽松的类型检查,所以使用了第一种写法。

至于为什么这样写会绕过严格模式下的参数类型检查,我没有查到相关资料,就当它是一个不到万不得已才使用的后门吧,请谨慎地看待它!😄

不变

上面的内容可能已经让你昏昏欲睡了,别着急!我们还有最后一个概念:不变(Invariance)

从上面的内容其实可以举一反三,什么是不变呢?

不变意味着没有子类型关系可以被应用。如果如果 AB 的子类型,那么 F<A>F<B> 既不是子类型也不是父类型。

举一个典型的例子,我们上面提到了 getter 和 setter 函数,它们一个满足协变,一个满足逆变,我们如果把他们结合起来呢:

ts 复制代码
type Invariance<T> = (arg: T) => T

同样是类似的例子:

ts 复制代码
declare let engineerInvariance: Invariance<Engineer>
declare let tsEngineerInvariance: Invariance<TSEngineer>

engineerInvariance = tsEngineerInvariance // Error!
tsEngineerInvariance = engineerInvariance // Error!

显然,两行代码都会报错。

回头看看...

现在我们回过头来看看为什么 zustand 不能自动推断的问题。

文档中给出了一个最简单的 ts 类型实现:

ts 复制代码
declare const create: <T>(f: (get: () => T) => T) => T

const x = create((get) => ({
  foo: 0,
  bar: () => get(),
}))
// `x` is inferred as `unknown` instead of
// interface X {
//   foo: number,
//   bar: () => X
// }

我们分析一下 create 的函数参数 (get: () => T) => T 这一部分类型声明:

第二个 T 作为函数返回值,根据我们上面的结论:在 ts 函数中,返回值类型是协变的。 此时参数类型是协变的。

第一个 T 稍微复杂一点,大家可以写个 demo 测试一下,这里说结论:参数类型是逆变的。

Boom! 这样的组合既不是协变,不是逆变的,是我们上面提到的最后一个概念------------不变(Invariance)

在这种场景下,ts 想要从 create 函数的参数中进行类型推断,但是这里将要被推断的范型是不变的,TypeScript 就无法推断它。

有没有办法解决这个问题呢?

我们可以换一种写法试试,我们不使用第一个 T,不使用 get 方法, 这样函数参数就会变成 () => T,在 ts 编译器的眼中,范型变成了协变,比如我们改写一下最开始的例子:

ts 复制代码
const useBearStore = create(() => ({
    bears: 0,
}))

cool!create 的返回值能自动推断出来!但是这样写有什么用?我们的 increase 方法怎么办?

幸运的是,zustand 支持我们这样写,把 action 和 store 拆开:

ts 复制代码
const increase = () =>
  useBearStore.setState((state) => ({
    bears: state.bears + 1,
  }))

🎉🎉 这样我们就不用手写范型类型了。

zustand 在官方文档中对自动推断的问题有更详细的解释,如果你感兴趣,可以查阅👉 Why can't we simply infer the type from the initial state?

总结

回顾一下几个概念: 对于范型函数 F, 如果 AB 的子集,即 A extends B,有:

  • 协变:F<A> extends F<B>
  • 逆变:F<B> extends F<A>
  • 双变 F<A> extends F<B> & F<B> extends F<A>
  • 不变:既不是逆变也不是协变🤷

协变和逆变的概念在大部分的日常工作中可能并用不上,正如前面所说,ts 团队似乎也有意不让开发者接触这些晦涩的概念,其官网中很少提到过逆变和协变相关的概念。只要在当你遇到一些让人困惑的场景时,可能需要了解一些 ts 的工作方式,希望本文可以帮到你~

如有错误,欢迎指正。感谢阅读。

相关推荐
mon_star°5 分钟前
将答题成绩排行榜数据通过前端生成excel的方式实现导出下载功能
前端·excel
Zrf21913184559 分钟前
前端笔试中oj算法题的解法模版
前端·readline·oj算法
文军的烹饪实验室1 小时前
ValueError: Circular reference detected
开发语言·前端·javascript
Martin -Tang2 小时前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发2 小时前
解锁微前端的优秀库
前端
王解3 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁3 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂3 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐4 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成6 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css