如何利用 Typescript 的类型编程自动推断 Redux reducer 的类型

2,381 阅读12分钟

别看了,直接用 tagged-enum

今日,使用 ts 几乎已经变成前端的政治正确了。虽然 ts 的自动类型推导已经很强大了,但受限于 js 语言本身,我们依然需要手写很多的类型,并且手动的去指定。

比如说在使用 ts 编写 redux 的 reducer 时,我们该怎样保证 action 类型的正确性呢?

简单,但不正确

最简单的,我们可以这么写


type Action = {
	type: string;
  payload: unknown;
}

function reducer(state: {}, action: Action) {
 // do something 
}

image.png

这种写法非常的简单,但是这样写 ts 无法帮我们推导出 payload 类型,如果我们想要使用 payload 属性只能手动使用 as 语法转换。在这种情况下,我们保证代码的正确性靠的是人眼推导,这种推导是很不靠谱的,几乎是完全背离了我们使用ts的初衷!

可辨识联合类型

在现实世界中,我们进行分类最常见的手段就打标签,如果 ts 中也有这种 打标签 的行为,该多好呢。

来认识下 ts 的一个特性 可辨识联合类型

这里借用下 ts 官网的例子

interface Square {
    kind: "square";
    size: number;
}

interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

interface Circle {
    kind: "circle";
    radius: number;
}

注意这三个 interface 的 .kind 属性的 类型! 不是 string ,而是一个 字面量 类型。

ts 中的字面量类型,有 numberstring ,字面量类型限制一个变量可以被赋予的值!

let hiWorld: "Hello World" = "Hi World";
// Error: 不能将类型“"Hi World"”分配给类型“"Hello World"”

刚才,我们提到了标签,其实这个 .kind 属性,就是我们对相似但又有差异的对象打的标签!通过区分这个标签,我们就能,得到 对应值的具体类型

type Shape = Square | Rectangle | Circle;

function area(s: Shape): number {
    // In the following switch statement, the type of s is narrowed in each case clause
    // according to the value of the discriminant property, thus allowing the other properties
    // of that variant to be accessed without a type assertion.
    switch (s.kind) {
        case "square": return s.size * s.size;
        case "rectangle": return s.width * s.height;
        case "circle": return Math.PI * s.radius * s.radius;
    }
}

这里 Shape 就是一个 联合类型 ,那么他的 可辨识 体现在哪里呢?

从代码中,可以看到传进来的 s 变量可能是 Square,Rectangle 或 Circle,他们有相似的地方,又有不同的地方。那么在函数内部,我们怎么编写相应的代码逻辑,并且保证类型正确呢?

答案就是,我们可以通过判断 .kind 属性的值。ts 会根据 .kind 的值,将 s 的类型归约到某一具体类型。体现在代码里就是,当 s.kind === square 时,ts 就可以判定 s 上一定也具有 s.size 属性。对于 Rectangle,Circle 也是同理,也就体现了 Shape 作为联合类型的可辨识性。

正确但繁琐的写法

让我们利用这个特性来编写 redux 的代码,我们目标是 在 reducer 内部可以根据 .type 属性来区分不同的 action 类型,并得到正确的类型推断!

假设有以下文件

// actionTypes.ts

export const ADD_TODO = 'ADD_TODO';

export const REMOVE_TODO = 'REMOVE_TODO';

// actionCreators.ts

import * as Types from './actionTypes';

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
});

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
});

// reducer.ts

import * as Types from './actionTypes';

export default function reducer(state = {}, action: Actions) {
    switch (action.type) {
        case Types.ADD_TODO: {

        } break;
        case Types.REMOVE_TODO: {


            action.payload
        } break;
    }
}


我们该怎么样编写类型,使得在 reducer 函数内部可以拿到正确的 action 类型呢?

我们先根据 actionCreator 函数们的逻辑来定义 Action 类型

// actionCreators.ts

import * as Types from './actionTypes';

type AddTodoAction = {
    type: typeof Types.ADD_TODO;
    payload: string;
}

type RemoveTodoAction = {
    type: typeof Types.REMOVE_TODO,
    payload: number;
}

这里需要注意的是,我们对 .type 属性的类型定义,为什么我们不简单的使用 string ,而是要使用 typeof 才行。

image.pngimage.png

因为只有这样,我们才能保证 type 的类型是 字面量类型,注意观察上面两个图片中,ts 对 .type 属性的推断值,而这个 .type 属性,将成为我们区分不同 Action 类型的关键!

然后我们将这些 Action 类型联合起来

import * as Types from './actionTypes';

type AddTodoAction = {
    type: typeof Types.ADD_TODO;
    payload: string;
}

type RemoveTodoAction = {
    type: typeof Types.REMOVE_TODO,
    payload: number;
}

export type Actions = AddTodoAction | RemoveTodoAction;

此时,我们距离完成目标已经不远了,剩下的就是将 **Actions **类型标注到 reducer 的参数上即可。

在标注后,ts 已经可以自动推倒出 action.type 的值可能是什么了! 截屏2020-08-13 下午9.46.25.png

如果我们在 case 处提供了错误的 type 值,ts 会报错,并提示我们 case 处允许的值

image.png

更重要的是,ts 已经可以根据 type 值的不同来推导出 .payload 的类型了!

image.png

image.png

实际上到这一步还不算完结,我们最终的目的是想要 actionCreator 函数的返回的 action 对象和 reducer 内部接受的 action 的值是一样的!

该怎么保证呢?非常简单,我们需要把刚才定义好的 Action 类型,标注到相应 actionCreator 函数上

// actionCreator.ts

import * as Types from './actionTypes';

type AddTodoAction = {
    type: typeof Types.ADD_TODO;
    payload: string;
}

type RemoveTodoAction = {
    type: typeof Types.REMOVE_TODO,
    payload: number;
}

export type Actions = AddTodoAction | RemoveTodoAction;

export const createAddTodo = (text: string): AddTodoAction => ({
    type: Types.ADD_TODO,
    payload: text,
});

export const createRemoveTodo = (id: number): RemoveTodoAction => ({
    type: Types.REMOVE_TODO,
    payload: id,
});

如此我们就完成了 actionCreator 和 reducer 的连接!这一点非常重要,只有这样,我们才能借助 ts 的类型保证 actionCreator 返回的值一定和 reducer 内部的 action 类型是一致的。

使用 ReturnType 简化

一些有经验的 ts 开发者,可能会建议使用 ReturnType 类型函数来优化上述代码,因为 actionCreator 返回的是 action 对象,为什么我们不使用 ReturnType 直接拿到函数的返回值类型,作为对应的 Action 类型呢,这样就自动完成了关联操作。

如:

import * as Types from './actionTypes';

type AddTodoAction = ReturnType<typeof createAddTodo>;

type RemoveTodoAction = ReturnType<typeof createRemoveTodo>;

export type Actions = AddTodoAction | RemoveTodoAction;

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
});

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
});

问题

让我们来看看此时 reducer 内部 action 的类型

image.png image.png

推断失败,为什么会这样呢?让我们来看下 AddTodoAction 和 RemoveTodoAction 的类型到底是什么!

image.png

image.png

可以看出 .type 属性被推断为 string 了,我们知道可辨识联合类型的前提是字面量类型,这里是 string 类型自然就不行了

那么是否有一种方法,告诉 ts 在进行类型推断时告诉 ts,我是一个字面量类型,不要把我简化归约!

为什么这样做,因为使用 ReturnType 类型函数,使得我们可以自动的根据 actionCreator 实际返回值来计算相应的 Action 类型,而不用手动去修改 Action 类型,然后再去修改 actionCreator 的实现逻辑。

as const 语法

我们来看下官方对 as const 的介绍

TypeScript 3.4 introduces a new construct for literal values called const assertions. Its syntax is a type assertion with const in place of the type name (e.g. 123 as const). When we construct new literal expressions with const assertions, we can signal to the language that

  • no literal types in that expression should be widened (e.g. no going from "hello" to string)
  • object literals get readonly properties
  • array literals become readonly tuples
// Type '"hello"'
let x = "hello" as const;

// Type 'readonly [10, 20]'
let y = [10, 20] as const;

// Type '{ readonly text: "hello" }'
let z = { text: "hello" } as const;

从官方的介绍中,对我们最有用的是

no literal types in that expression should be widened (e.g. no going from "hello" to string)

这句话的意思是,通过 as const 被声明的字面量类型,在类型推导中不会被扩展成为 "父类型",比如对于字面量类型 "hello" 来讲,在类型推导中会被扩展为 string 类型,对于字面量量类型 9999 来说,在推导中会被扩展为 number 类型。

利用这个特性,我们可以在定义 actionType 时候这样写

export const ADD_TODO = 'ADD_TODO' as const;

export const REMOVE_TODO = 'REMOVE_TODO' as const;

这时候,我们再去看看 ts 对 actionCreator 函数返回值类型的推断

image.png image.png

我们再看看 ReturnType 后的值

image.png image.png

和我们刚才使用 typeof 的效果一样!这里为了体现,使用 ReturnType 的好处,我们去更改下 actionCreator 的逻辑,我们在 action 对象上挂一个 .time 属性表示对象创建的时间。

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
    time: new Date(),
});

这个时候,我们去看下 reducer 内部的类型推导

image.png

可以看到 action 的类型依旧是正确的,并且在我们添加 time 属性后,推导后的类型同样增加 time 属性和其类型!

同样的,你也可以这么写,直接在定义 actionCreator 时候去 as const

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
    time: new Date(),
}) as const;

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
}) as const;

image.png

依旧可以得到我们想要的类型推导,但是这样会使得 action 对象的所有属性变成 readonly ,虽然我觉得这么做没问题,你确实不应该对 action 对象作出更改。

到此为止,我们已经达到我们的目的了,保证 reducer 内部类型的正确性,只不过是又要多写一点类型相关的代码。

类型编程

在实际编写类型的过程中,其实可以注意到,我们进行类型标注的行为,使用 ReturnType 类型函数,多么像在编程,我们传进去一个类型,得到一个新类型。那么是否,我们可以利用这种类型编程的能力,做到自动类型推断呢?

我们只要编写 actionType 和 actionCreator ,reducer 内部会自动的得到 action 类型,不需要我们手动去指定。

先看看我们现在已经有了什么,在我们通过 as const 定义过 actionType 后,我们可以通过 ReturnType 拿到对应 actionCreator 函数返回值的类型,而这个类型,正好是我们需要的 Action 的类型,最后我们标注到 reducer 的参数即可。

所以我们现在做的应该是,怎么获取 actionCreators.ts 文件里的所有 actionCreator 函数类型,并且循环的将所有他们的返回值类型抽出来,并组合成一个可辨识联合类型。

获取所有的 actionCreator 函数类型

简单需要妥协的做法

我们先解决,如何获取所有的 actionCreator 函数类型?最简单的做法是将所有 actionCreator 定义到一个对象上,我们导出这个对象即可。

export const actionCreators = {
    createAddTodo: (text: string) => ({
        type: Types.ADD_TODO,
        payload: text,
        time: new Date(),
    }) as const,
    createRemoveTodo: (id: number) => ({
        type: Types.REMOVE_TODO,
        payload: id,
    }) as const,
}

image.png

通过图片可以看到,我们已经拿到一个包含着 actionCreator 函数类型的对象类型了。

偏要勉强的做法

现在我们已经获取到需要的 actionCreator 函数的类型了,只是我们需要将所有的 actionCreator 函数定义到一个对象上。那如果我偏不,偏要勉强,偏要在不改变编写习惯的基础上,达到同样的目标呢。

在冥思苦想之后,我想到了** import * as [name] from [module] **语法

image.png

可以看到,我们依然成功得到了想要的类型,并且在使用 typeof 后,ts 会贴心的将原模块导出类型声明过滤掉,但这里其实有个很大的问题,如果有人在 actionCreators.js 导出了其他变量会发生什么?

问题

假设,有人导出一个 foo 变量

import * as Types from './actionTypes';

type AddTodoAction = ReturnType<typeof createAddTodo>;

type RemoveTodoAction = ReturnType<typeof createRemoveTodo>;

export type Actions = AddTodoAction | RemoveTodoAction;

export const foo = 'foo';

export const createAddTodo = (text: string) => ({
    type: Types.ADD_TODO,
    payload: text,
    time: new Date(),
}) as const;

export const createRemoveTodo = (id: number) => ({
    type: Types.REMOVE_TODO,
    payload: id,
}) as const;

截屏2020-08-14 下午3.31.46.png

手动过滤

可以看到 RawActionCreators 多了一个 foo 属性的类型,尽管我们可以手动的去过滤

image.png

但是这就背离我们的初衷了,我们想要自动过滤!

我们需要想办法把除了 ActionCreator 之外的类型过滤掉。既然要过滤,那我们就要先确定 什么是一个 ActionCreator ?

这里我们对 ActionCreator 下一个定义,一个接受不定参数,返回一个 Action 对象的函数,就是 ActionCreator

什么又是 Action 呢?一个带有 .type 属性的对象就是 Action 对象。

type Action = {
    type: string;
    [otherKey: string]: unknown;
};

type ActionCreator = (...args: unknown[]) => Action;

自动过滤

然后就是魔法发生的时刻了! **

type ExtractActionCreaotrKey<T> = {
    [K in keyof T]: T[K] extends ActionCreator ? K : never;
}[keyof T]

成功

通过 ExtractActionCreaotrKey 类型函数,我们就能正确获取一个对象类型上,所有类型为 ActionCreator 的 key 名!

image.png

可以看到 foo 被过滤掉了。

现在我们来解释下这段代码,这段代码的核心在于 ts 的另一个特性,条件类型

T[K] extends ActionCreator ? K : never

T 是我们传的对象类型,K 则指代对象类型上的 key 名 ,T[K] 则表示某个 key 对应的类型,extends 关键字代表着 被包含 关系 , 这段代码的意思就是 如果 T[K] 类型是 ActionCreator 或其子集则返回 K(也就是这个类型对应的 key 名),否则返回 never 类型。

部分人可能会被最后一行的 [keyof T] 搞困惑掉,这里我们来看下,如果没有它的结果是什么。

image.png

可以看到 foo 属性的类型是 never ,其他属性的类型则变成了他们的 key 名,非常符合我们编写的条件类型的逻辑,但是我们最终的目的是拿到 ActionCreator 函数类型的 key 名,而最后的 [keyof T] 就是起到这步操作。

我们拿到了对象类型所有的值,他们的值恰好对应着他们 key 名,自然而然我们就完成我们的目的了。never 类型则在这个过程中消失掉了。

此时,我们距离自己的目标已经非常近了!现在我们只需要将得到 key 名,填入到 RawActionCreators,就可以只拿到 ActionCreator 的类型了!

image.png

image.png

可以看到效果和我们手动写的一样,只不过这一切是自动的,也就是说一旦你在 actionCreators.ts 里定义了一个新的 actionCreator 函数,那这里的 ActionCreators 会自动得到其对应的函数类型!

最终结果

现在我们只需一步,就可以给 reducer 标注类型了!

image.png

可以看到 ReducerAction 的类型是联合过的 Action 类型!

image.png image.png

经过标注,reducer 内部成功推断出了 action 参数的类型。

我们得到了什么

自动的类型推断,换句话说,我们只需要定义 actionType 和编写 actionCreator 函数 ,然后 reducer 内部就会自动加入相关的类型信息 **

封装

最后的最后,我们将刚才的类型相关代码封装成为一个类型函数,它传进去一个对象类型,然后将所有 的Action 类型联合后返回。通过这个类型函数,我们很简单给 reducer 标注上类型,并且我们需要付出的改变,只是需要把 actionType 使用 as const 定义下。

// utils.ts

type ActionCreator = (...args: any[]) => {
  type: string;
  [otherKey: string]: unknown;
};

type $ExtractActionCreatorKey<T> = {
  [K in keyof T]: T[K] extends ActionCreator ? K : never;
}[keyof T]

type $ExtractActions<T extends Record<string, ActionCreator>> = ReturnType<T[$ExtractActionCreatorKey<T>]>;


// 使用方式

import * as hasActionCreators from './hasActionCreators';

type Actions = $ExtractActions<typeof hasActionCreators>;

export default function reducer(state = {}, action: Actions) {
  // do something
}