写在前面
太长不看👉 关于逆变和协变,可以看最后的总结
文中的例子可以在在 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() 可能会导致运行时报错。所以我们认为这一行代码是不安全的。
反之第一行代码是安全的。
从中我们总结一下,协变的规则和上面提到的基础例子中的规则是一样的:
如果 A
是 B
的子类型,并且 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)。
从中我们总结一下,逆变的规则和协变正好相反:
如果 A
是 B
的子类型,并且 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)
从上面的内容其实可以举一反三,什么是不变呢?
不变意味着没有子类型关系可以被应用。如果如果 A
是 B
的子类型,那么 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, 如果 A
是 B
的子集,即 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 的工作方式,希望本文可以帮到你~
如有错误,欢迎指正。感谢阅读。