- 前端
【免费下载链接】easy-peasy
Vegetarian friendly state for React
本篇聚焦 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 / dispatch | state.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.ts | Generic类与generic()函数声明 |
| index.d.ts | StateMapper中Generic<infer T>的解包分支 |
| index.d.ts | Action<Model, Payload>类型定义 |
| src/helpers.js | generic()运行时透传实现 |
| 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
相关推荐
TypeHero 挑战详解:掌握 TypeScript 默认泛型参数(Default Generic Arguments)
TypeHero 挑战详解:掌握 TypeScript 默认泛型参数(Default Generic Arguments) 导读 本篇围绕 TypeHero 仓
教育前端后端Easy Peasy EffectOn 类型详解:为 effectOn 响应式副作用构建精准的 TypeScript 模型
Easy Peasy EffectOn 类型详解:为 effectOn 响应式副作用构建精准的 TypeScript 模型 本文以 Easy Peasy 官方的
前端TypeHero 挑战解析:TypeScript 泛型类型参数(Generic Type Arguments)从入门到实战
TypeHero 挑战解析:TypeScript 泛型类型参数(Generic Type Arguments)从入门到实战 导读 本文以 TypeHero 仓库
教育前端后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考