TypeScript的类型系统早已不只是给变量贴标签的工具,它本身就是一门图灵完备的语言。在状态管理场景里,动作创建器(Action Creator)往往是最容易写出类型噪音的地方:一堆字符串常量、一堆payload接口、一堆手写的联合类型,改一个action要动三个文件。本文就来聊聊如何用类型级编程把这些样板代码交给编译器处理。

传统动作创建器写法的类型痛点
先看一段典型的Redux风格代码。大多数项目里,动作创建器长这样:
// 手写一个type常量
const INCREMENT = 'counter/increment';
const SET_NAME = 'user/setName';
// 手写对应的action接口
interface IncrementAction {
type: typeof INCREMENT;
payload: number;
}
interface SetNameAction {
type: typeof SET_NAME;
payload: string;
}
// 手写联合类型
type CounterAction = IncrementAction | SetNameAction;
// 动作创建器函数
function increment(amount: number): IncrementAction {
return { type: INCREMENT, payload: amount };
}
function setName(name: string): SetNameAction {
return { type: SET_NAME, payload: name };
}这种写法的问题很明显:同一个action的信息被重复描述了三遍——常量定义一遍、接口声明一遍、创建器返回值再写一遍。一旦新增一个action,需要改动的地方分散在多处,漏掉任何一处,联合类型就不再完备,reducer里的类型收窄也会失效。
更隐蔽的问题是字符串字面量与接口之间只靠typeof维系,一旦有人把常量的值改了但忘了同步接口注释,运行时的字符串匹配依然正确,但代码可读性会迅速下降。类型噪音本质上来自一个事实:我们让人类去做本该编译器做的推导工作。
用映射类型自动生成动作联合
解法是把action的定义收敛到一个对象里,然后用映射类型推导出所有需要的东西。看下面的写法:
// 把所有action定义集中到一个对象里,key是type,值是payload类型
interface ActionPayloads {
'counter/increment': number;
'user/setName': string;
'user/reset': undefined;
}
// 用映射类型 + keyof 生成完整的联合类型
type AppAction = {
[K in keyof ActionPayloads]: {
type: K;
payload: ActionPayloads[K];
};
}[keyof ActionPayloads];这段代码的核心是一个索引访问操作。[K in keyof ActionPayloads]先为每个key生成一个带type和payload的对象类型,紧接着的[keyof ActionPayloads]把映射结果的所有属性值取出来组成联合类型。编译后得到的等价类型就是{ type: 'counter/increment'; payload: number } | { type: 'user/setName'; payload: string } | ...。
这样做的好处是单一数据源:新增action只需要在ActionPayloads里加一行,联合类型自动更新,不可能出现漏配的情况。reducer里对action.type做switch判断时,TypeScript会基于判别联合自动收窄payload类型,写case 'user/setName'时编辑器已经知道payload是string了。
泛型工厂与infer提取payload类型
接下来解决动作创建器本身的类型问题。既然payload类型信息已经在ActionPayloads里了,创建器完全可以自动生成:
// 泛型工厂:传入type字符串,自动推导payload类型并返回正确的创建器
function createAction<K extends keyof ActionPayloads>(type: K) {
return function (payload: ActionPayloads[K]): AppAction {
return { type, payload } as AppAction;
};
}
const increment = createAction('counter/increment');
const setName = createAction('user/setName');
increment(5); // 合法,payload是number
setName(42); // 编译报错,payload要求string这里的K extends keyof ActionPayloads是关键约束:type参数只能传已定义的字符串字面量,拼错一个字母就会直接编译报错,而不是等到运行时才发现reducer没有匹配到任何case。
如果想要更进一步的类型体操,可以用infer在类型层面做反向查询。比如定义一个工具类型,从创建器函数本身提取出它产出的action类型:
// 从函数类型中用infer提取返回值里的action
type ActionTypeOf<T> = T extends (
...args: never[]
) => infer R
? R extends { type: infer U }
? U
: never
: never;
type IncrementType = ActionTypeOf<typeof increment>;
// 结果为 'counter/increment'infer的作用是在条件类型的匹配过程中声明一个待推断的类型变量。上面第一个infer R抓的是函数返回值,第二个infer U抓的是返回值对象里type字段的具体字面量类型。这种技术在写中间件或者需要按type分组订阅action的场景里非常实用,比如实现一个类型安全的ofType过滤操作符。
模板字面量类型与类型安全的命名空间
当项目规模变大,action的type通常带命名空间前缀,比如user/login/request、user/login/success。手写这些字符串容易出错,模板字面量类型可以在类型层面约束它们:
// 定义合法的阶段
type Stage = 'request' | 'success' | 'failure';
// 用模板字面量类型拼出所有合法的异步type
type AsyncActionType<T extends string> = `${T}/${Stage}`;
// 组合出具体的type集合
type UserTypes = AsyncActionType<'user/login'>;
// 'user/login/request' | 'user/login/success' | 'user/login/failure'再配合一个映射类型给每个阶段分配不同的payload类型,一个完整的异步流程类型就声明完毕了。这种写法把字符串拼接的合法性检查放到了编译期,拼写错误、大小写不一致这类低级问题在写代码的瞬间就会被红线标出。
实践建议与取舍
类型级编程不是越复杂越好。上面这些技巧的价值在于消除重复和把错误前移,如果团队对条件类型不熟悉,可以从映射类型生成联合这一步开始,收益最大、心智负担最小。infer和递归类型则建议在封装通用工具库时再引入。
另外要留意编译耗时:大量的模板字面量类型组合在超大项目里可能拖慢IDE响应。一个实用的经验是保持action定义的集中式对象不要过度嵌套,必要时按模块拆分成多个payload映射对象,再用交叉或联合类型组合,既保住单一数据源,又控制住类型展开的规模。
总结一下思路:把action的定义收敛为一份类型数据,让映射类型生成联合、让泛型工厂生成创建器、让模板字面量类型管住字符串。类型系统接管了样板代码之后,你写状态管理的体验会发生质的变化。
TypeScript类型系统动作创建器修改时间:2026-09-04 04:44:37