从zustand中窥探typescript 逆变、协变、双变和不变

zustand 基本使用如下

ts 复制代码
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 })),
}))
​
​
function Comp() {
  const [brears, increase] = useBearStore(s => [s.brears, s.increase]);
  return (
    <>
      <span>{brears}</span>
      <button onClick={() => increase(1)}>add brear</button>
    </>
  )
}

使用起来非常方便简洁,但是你可能会注意到通过typescript编写代码时,必须给create方法声明 BearState 类型,typescript不是可以自动推导泛型类型么?为什么zustand无法自动推导store类型?

那为什么redux可以自动推导store 类型?

ts 复制代码
import { createSlice } from '@reduxjs/toolkit'
​
const bearStore = createSlice({
  initialState: {
    bears: 0,
  },
  reducers: {
    increase: (state) => {
      state.bears += 1 // bears: number
    }
  }
})

其实zustand官方文档已经给出了解释:

大概的意思是,因为create中定义的state泛型T被推断为 invariant (不变),所以typescript无法自动推导T的实际类型。如果我们不了解ts这些概念,就会感觉明明每个字都认识,为什么连起来就看不懂了?🤡

让我们通过几个例子来简单了解什么事ts中的协变、逆变、不变和双变。我们先记住一个重要的概念,typescript一切都是为了保护类型安全

首先定义两个类

ts 复制代码
interface Jser {
  codeJS: () => void;
}
​
interface Tser extends Jser {
  codeTS: () => void;
}
​
let jser: Jser = {codeJS: () => {}};
let tser: Tser = {codeTS: () => {}, codeJS: () => {}};

其中 Tser 是 Jser 的子类型

协变(covariance)

协变比较容易理解,有如下例子

这里应该比较容易理解,因为子类 tser 中所有类型都满足 jser , 所以我们在使用 jser 变量是不会有任何问题的(虽然这俩类型不一样,但这样赋值类型是安全的)。

反之如果将父类 jser 赋值给子类 tser,有可能在实际使用过程中会调用 tser.codeTS, 然而 jser 并没有这个属性,所以此时类型时不安全的,ts 就会报错。

现在让我们加入泛型进一步分析

为什么 getJser 赋值给 getTser 会报错?其实与上面的分析差不多,假设可以编译通过,因为调用getTser函数时还是按照 GetEr<Tser> 类型约束,所以实际运行时可能会访问到无效属性,类型是不安全的

ts 复制代码
const er = getTser();
er.codeTS() // error. 因为此时getTser实际为getJser, 返回的er是jser, 并没有codeTs, 所以运行时会报错

总结: 子类型可以赋值给父类型的情况就叫做协变。ts函数返回值类型是协变的

逆变(contravariance)

假设有如下两个函数

这个场景恰好与协变相反,为什么 setJser 函数可以赋值给setTser, 反之则不行呢?

假设 setJser = setTser 能编译通过,此时调用setJser 还是会按照 SetEr<Jser> 类型约束,那么当我们执行函数 setJser(jser) 时就会报错,因为此时 setJser 实际上为 setTser, 我们将 jser 传给了 setTser, 当执行 jser.codeTS()时发现 jser 上并没有 codeTS 函数,就会发生运行时错误,这种情况下ts类型是不安全的

相当于

ts 复制代码
setTser = (er: Tser) => {
  // jser并无 codeTS, 运行时会报错
  er.codeTS();
};
​
setTser(jser)
​

setJser 可以赋值给 setTser 是没有问题的,因为在这种情况下,ts类型是安全的。执行 setTser 时就是在调用 setJser, 然后传入一个 tser 参数,都能满足setJser的任何属性或方法调用

相当于

ts 复制代码
setJser = (er: Jser) => {
  // tser都能满足Jser所需的类型
  er.codeJS();
};
​
setJser(tser)

这种场景就是逆变,总结一下为:父类型可以赋值给子类型的情况就叫做逆变,ts函数参数类型是逆变的

为什么要有逆变协变等操作?我理解是typescript为了不让类型安全的限制太死板,让其更有灵活性。比如子类型是可以赋值给父类型的变量的,可以完全当成父类型来使用

双变(bivariance、双向协变)

双变是指既可以是逆变也可以是协变,也就是父类型可以赋值给子类型,子类型可以赋值给父类型

在 ts 2.6之前,函数参数类型默认是双变的,允许更宽泛的函数参数传值,但也带来了一些安全性问题。在 ts 2.6 之后,新增了 strictFunctionType 配置,默认为true, 即函数参数类型是逆变的,设为false表示函数参数类型是双变的

比如上述逆变的例子,我们在 tsconfig.json 中新增 strictFunctionType: false, 那么ts报错就会消失

不变(Invariance)

从上述内容我们可以推断出不变就是既不能协变也不能逆变,只要类型不一样就报错

举个例子,我们将上述的 GetEr<T>SetEr<T>(一个满足协变,一个满足逆变) 结合起来生成一个新类型

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

函数类型 Invariance 是不变的(因为ts无法推断到底是协变还是逆变,无论是哪种型变都会有安全性问题)。

由于两个函数都是不变的,所以相互赋值都会报错

回头再看为什么zustand不能自动推断store类型

因为 create<T>类型中的泛型T是不变的

其实zustand官网给了一个最简单的例子

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返回值是T,所以此时可以考虑 T 为协变的

我们来看f的类型 (get: () => T) => T , 第二个T为函数的返回值,根据上述结论:ts函数返回值是协变的,所以此时T为协变的

第一个T比较复杂可以将get的类型 () => T 考虑为一个整体参数,根据上述结论:ts函数参数是逆变的,所以此时T为逆变的

既是协变又是逆变(鸡生蛋?蛋生鸡?)?此时ts只能认为T是不变的。如果泛型T是不变的,那么ts就无法默认推断出T的实际类型

为什么不变就无法推到类型呢?我们可以换个思路想想。

zustand可以将state和action一些写到store中(其实zustand内部并不会区分state和action,都视为state)。

create函数接收一个 createState 函数作为参数,createState 函数第二个参数为get, get的函数签名为() => T, 因为create 需要返回T, 而T中包含所有的action,而 action中又可以调用get获取state(T),那么这个T不就相当于死循环了么? 根本无法推断T的具体类型

ts 复制代码
 const useBearStore = create((set, get) => ({ bears: 0, increase: () => {
   const states = get();
   set((state) => ({ bears: state.bears + 1 }));
 }))

如果使用zustand你实在不想定义store类型,我们可以换一种写法,不使用第一个T。也就是将state和action 拆开

这样create的返回值能自动推断出来(协变)

这也是为什么redux能自动推导store类型的原因,redux就是将state和action分开的写法

zustand一些类型骚操作

骚操作一

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> 满足逆变的规则,参数类型是逆变的。而第一种方式会绕过严格模式下的参数类型检查,将参数类型变为双变的(是的,即使开启了严格模式也不会变为逆变!)。

可能是在 zustand 代码中,需要更宽松的类型检查,所以使用了第一种写法。但是为什么第一种写法能产生这样的效果,不知道😛

骚操作二

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

你有没有想过为什么我们需要柯里化调用create函数么?

zustand同样支持非柯里化调用

ts 复制代码
const useBearStore = create((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
}))

那么柯里化调用的意义是什么?

让我们来看下面的例子

假设我们有如下场景

ts 复制代码
declare const withError: <T, E>(
  p: Promise<T>,
) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
declare const doSomething: () => Promise<string>
​
const main = async () => {
  let [error, value] = await withError(doSomething())
}

这里 T 被推导为 string, E 被推导为 unknow 。有时我们可能想跳过 T 声明 EFoo , 因为此时你可以确定 doSomething() 将抛错。但遗憾的是ts不支持这样做,你必须先声明 Tstring ,然后才能再声明 EFoo,即使声明 Tstring 没有任何意义(可以被推断出来)

ts 复制代码
withError<, Foo> // error
withError<string, Foo> 

另一种解决方式是重载 withError 类型成为柯里化版本,运行时不做任何额外的逻辑,仅仅是为了让我们可以跳过 T 只声明 E

ts 复制代码
declare const withError: {
  <E>(): <T>(
    p: Promise<T>,
  ) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
  <T, E>(
    p: Promise<T>,
  ): Promise<[error: undefined, value: T] | [error: E, value: undefined]>
}
declare const doSomething: () => Promise<string>
interface Foo {
  bar: string
}
​
const main = async () => {
  let [error, value] = await withError<Foo>()(doSomething())
}

使用柯里化方式,T 可以被自动推导同时我们可以只声明 E ,zustand create 函数使用相同的方式让我们只声明state泛型(第一个类型参数),其他泛型自动推导。

ts 复制代码
type CreateStore = {
  <T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
    initializer: StateCreator<T, [], Mos>,
  ): Mutate<StoreApi<T>, Mos>
​
  <T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
    initializer: StateCreator<T, [], Mos>,
  ) => Mutate<StoreApi<T>, Mos>
}

详情请参考:docs.pmnd.rs/zustand/gui...

相关推荐
周三有雨21 小时前
vue3 + vite 实现版本更新检查(检测到版本更新时提醒用户刷新页面)
前端·vue.js·typescript
清灵xmf2 天前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
Amd7943 天前
Nuxt.js 应用中的 prepare:types 事件钩子详解
typescript·自定义·配置·nuxt·构建·钩子·类型
王解4 天前
Jest项目实战(2): 项目开发与测试
前端·javascript·react.js·arcgis·typescript·单元测试
鸿蒙开天组●4 天前
鸿蒙进阶篇-网格布局 Grid/GridItem(二)
前端·华为·typescript·harmonyos·grid·mate70
zhizhiqiuya4 天前
第二章 TypeScript 函数详解
前端·javascript·typescript
初遇你时动了情4 天前
react 18 react-router-dom V6 路由传参的几种方式
react.js·typescript·react-router
王解4 天前
Jest进阶知识:深入测试 React Hooks-确保自定义逻辑的可靠性
前端·javascript·react.js·typescript·单元测试·前端框架
_jiang5 天前
nestjs 入门实战最强篇
redis·typescript·nestjs
清清ww5 天前
【TS】九天学会TS语法---计划篇
前端·typescript