从 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 的工作方式,希望本文可以帮到你~

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

相关推荐
passerby606131 分钟前
完成前端时间处理的另一块版图
前端·github·web components
掘了39 分钟前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅42 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅1 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment2 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅2 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊2 小时前
jwt介绍
前端
爱敲代码的小鱼2 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc