☰
Easy Peasy Generic/generic 详解:为 TypeScript 泛型模型状态寻找类型安全的出路
2026/10/14 14:53:45 网站建设 项目流程
  • 前端

【免费下载链接】easy-peasy

Vegetarian friendly state for React

项目地址:https://gitcode.com/gh_mirrors/ea/easy-peasy
点击查看免费下载

本篇聚焦 easy-peasy 的 TypeScript API 中专门处理"泛型状态"的Generic类型与generic()辅助函数。文章先讲清楚"为什么模型里的泛型状态会破坏 easy-peasy 的类型推导"这一底层机制,再给出可直接复制的完整用法示例,并结合源码与仓库测试用例剖析Generic的运行时实现与类型层面的过滤原理,帮助你在编写可复用、参数化的 model 时正确声明泛型 state。

背景问题:泛型状态为什么会破坏 TypeScript 推导

当你定义一个包含泛型 state 的 model 时,如果不做特殊处理,TypeScript 会在 actions 中报错。文档中给出的"坏掉"的例子如下(对应 website/docs/docs/typescript-api/generic.md 的 Background 一节):

interface StoreModel<K> { data: K; updateData: Action<StoreModel<K>, K>; } const numberStoreModel: StoreModel<number> = { data: 1337, updateData: action((state, payload) => { // 此处会抛出 TypeScript 类型错误 // 👇 state.data = payload; }) }

根因:State 类型的映射过滤机制

要理解这个报错,需要先理解 easy-peasy 的类型系统如何工作。easy-peasy 底层通过State和Action这两个工具类型对"用户提供的 model 类型"做一次映射,分别把成员过滤为纯 state 和 action creators。在 index.d.ts 中,StateMapper是关键:

type StateTypes = Computed<any, any, any> | Reducer<any, any> | ActionTypes; type StateMapper<StateModel extends object> = { [P in keyof StateModel]: StateModel[P] extends Generic<infer T> ? T : P extends IndexSignatureKeysOfType<StateModel> ? StateModel[P] : StateModel[P] extends Computed<any, any, any> ? StateModel[P]['result'] : StateModel[P] extends Reducer<any, any> ? StateModel[P]['result'] : StateModel[P] extends object ? /* 递归过滤 action/thunk 成员 */ : StateModel[P]; };

问题在于:当 state 是泛型参数(如K)时,TypeScript 无法确认K不与正在被过滤掉的 action 类型相交,于是泛型状态在过滤过程中总是被错误地"删掉",State<StoreModel<K>>中拿不到data,action回调里的state.data = payload便报类型错误。

由于这是当前 TypeScript 类型系统的限制(easy-peasy 官方曾在 StackOverflow 上提交问题详细说明),项目团队没有选择强行修好泛型本身,而是引入了一个新的 API 辅助对来规避它。

解决方案:Generic 类型与 generic 辅助函数

官方给出的最小影响方案是:在 model 接口中把泛型 state 用Generic<T>包一层,在 model 实例中用generic()辅助函数赋予初始值。两者定义在 index.d.ts 的 Generics 区域:

// 用于在 model 上声明泛型 state(纯类型标记) export class Generic<T> { type: 'ezpz__generic'; } // 用于在 model 实例中给泛型 state 赋值 export function generic<T>(value: T): Generic<T>;

注意Generic<T>是一个仅含type: 'ezpz__generic'标记字段的类,它本质上只是一个类型层面的包裹器,不参与 store 的实际数据结构。运行时实现更加直白,src/helpers.js 中generic就是一个恒等透传:

export function generic(value) { return value; }

也就是说generic(1337)在运行时就是1337本身,store 里存的是原始值,不会多出任何包装对象。这一点由运行时测试 tests/generic.test.js 直接验证:

test('generic values are passed through as state', () => { const store = createStore({ foo: generic(1337), }); expect(store.getState()).toEqual({ foo: 1337, }); });

而类型层面,正是StateMapper的第一个分支(index.d.tsL256-L257)对Generic<infer T>做"解包":

[P in keyof StateModel]: StateModel[P] extends Generic<infer T> ? T // 直接把 T 还原为最终 state 类型 : /* 其他过滤分支... */

通过infer T精确推断出被包裹的类型,绕开了"泛型参数可能与其他类型相交"的过滤困境,data得以在State<StoreModel<number>>中被正确解析为number。

完整示例:声明、赋值与读取

下面是文档给出的完整可运行示例(继承自 website/docs/docs/typescript-api/generic.md),逐行注释了Generic/generic各出现在哪里:

import { Generic, generic } from 'easy-peasy'; interface StoreModel<K> { data: Generic<K>; // 👈 在接口中用 Generic 声明泛型 state updateData: Action<StoreModel<K>, K>; } const numberStoreModel: StoreModel<number> = { data: generic(1337), // 👈 用 generic 辅助函数赋初始值 updateData: action((state, payload) => { // 注意:payload 不需要用辅助函数包裹 // 👇 state.data = payload; // 类型推导正常,data 被解析为 number }) } function MyComponent() { const data = useStoreState(state => state.data); const updateData = useStoreActions(actions => actions.updateData); console.log(data) // 1337 console.log(typeof numberStoreModel.getState().data) // number updateData(7331); // 👈 dispatch 时直接传原始值,无需包裹 }

三个要点可以对照记住:

位置写法说明
model 接口data: Generic<K>类型层面声明泛型 state
model 实例data: generic(1337)运行时透传,store 中实际存1337
action / dispatchstate.data = payload、updateData(7331)直接使用原始值,无需任何包裹

结合测试用例:更复杂的泛型 model 场景

仓库中的类型测试文件 tests/typescript/generic-model.ts 覆盖了文档示例之外的两类实战场景,可以直接参考。

场景一:工厂函数风格的泛型 model(对应 issue #300)

更常见的写法是把 model 做成工厂函数,在创建时确定类型参数:

interface SimpleModel<T> { count: number; thevalue: Generic<T>; theset: Action<SimpleModel<T>, T>; } const makeSimpleModel = <T>(initialValue: T): SimpleModel<T> => { return { count: 1, thevalue: generic(initialValue), theset: action((state, payload) => { state.count = 1; state.thevalue = payload; // payload 类型精确为 T // @ts-expect-error state.theset(); // state 上不存在 action,此处应报错 }), }; }; const numberModel = makeSimpleModel<number>(1337); const store = createStore(numberModel); store.getState().thevalue + 1; // thevalue 被正确解析为 number,可做数值运算

这个用例同时验证了两件事:state中theset已被State过滤掉(state.theset()必须报错,用@ts-expect-error断言);store.getState().thevalue是number而非泛型T,说明Generic<infer T>的解包对createStore推导的 state 类型同样生效。

场景二:泛型数组状态 + thunk(对应 issue #345)

Generic还可以包裹数组这类复合类型,并且与thunk协作:

interface Base { a: string; } interface Model<M extends Base> { data: Generic<M[]>; add: Action<Model<M>, M>; doSomething: Thunk<Model<M>, M>; } function getModel<M extends Base>(): Model<M> { return { data: generic([]), add: action((state, payload) => { payload.a + 'foo'; state.data.push(payload); // data 被解析为 M[],可调用 push }), doSomething: thunk((actions, payload) => { payload.a; actions.add(payload); }), }; }

这里data: Generic<M[]>声明后,state.data在 action 内被解析为M[],state.data.push(payload)类型安全。

边界与注意事项

  • 额外的样板是刻意为之:文档原文明确承认Generic/generic这套写法"略显冗长/令人困惑",但这是当时能想出的最小影响方案,并寄望于未来 TypeScript 版本迭代后废弃这些辅助函数。
  • 只用于泛型 state:Generic<T>的价值在于承载"尚未确定的类型"。对于非泛型的普通 state(如固定的Person[]),直接使用字面类型即可,类型测试中的 "NORMAL" 用例(tests/typescript/generic-model.ts)展示了不引入Generic也完全正常的情况。
  • 运行时零开销:从 src/helpers.js 看,generic()是纯透传,store 状态中没有Generic包装对象;Generic<T>类的type: 'ezpz__generic'只是类型标记,供StateMapper做infer解包使用。
  • API 文档交叉引用:generic作为运行时导出的 API 有独立条目 website/docs/docs/api/generic.md,其内容同样指向本文(TypeScript API 的Generic文档),两者应配合阅读。

相关源码与测试索引

文件内容
website/docs/docs/typescript-api/generic.md本篇对应官方文档
index.d.tsGeneric类与generic()函数声明
index.d.tsStateMapper中Generic<infer T>的解包分支
index.d.tsAction<Model, Payload>类型定义
src/helpers.jsgeneric()运行时透传实现
tests/generic.test.js运行时透传行为测试
tests/typescript/generic-model.ts泛型 model 类型级测试(含 #300、#345 场景)

总结:当你需要用 TypeScript 编写可参数化的 easy-peasy model 时,记住这一对"类型声明 + 运行时赋值"的组合——接口中写Generic<T>、实例中写generic(value)、读写 state 时一律使用原始值。它用最小的 API 代价解决了泛型 state 在State类型映射过滤中丢失的问题,是 easy-peasy 类型系统中最具针对性的一条逃生通道。

  • 前端

【免费下载链接】easy-peasy

Vegetarian friendly state for React

项目地址:https://gitcode.com/gh_mirrors/ea/easy-peasy
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询