При создании экземпляра стора нам не нужно предоставлять никаких дополнительных типов. Он создаст безопасный с точки зрения типов экземпляр Store, используя вывод типов.
Результирующие методы экземпляра стора, такие как getState или dispatch, будут проверены на тип и покажут все ошибки типа.
Мы будем использовать проверенную в боях библиотеку-помощник typesafe-actions, которая создана для того, чтобы сделать работу с Redux в TypeScript простой и увлекательной. Чтобы узнать больше, ознакомьтесь с этим подробным руководством: Typesafe-Actions - Tutorial!
Ниже представлено решение с использованием простой функции-фабрики для автоматизации создания безопасных для типов действий создателей. Цель состоит в том, чтобы уменьшить усилия по обслуживанию и сократить повторение кода аннотаций типов для действий и создателей. В результате получаются полностью безопасные для типов создатели действий и их действия.
/* eslint-disable */import{action}from'typesafe-actions';import{ADD,INCREMENT}from'./constants';/* SIMPLE API */exportconstincrement=()=>action(INCREMENT);exportconstadd=(amount:number)=>action(ADD,amount);/* ADVANCED API */// More flexible allowing to create complex actions more easily// use can use "action-creator" instance in place of "type constant"// e.g. case getType(increment): return action.payload;// This will allow to completely eliminate need for "constants" in your application, more info here:// https://github.com/piotrwitek/typesafe-actions#constantsimport{createAction}from'typesafe-actions';import{Todo}from'../todos/models';exportconstemptyAction=createAction(INCREMENT)<void>();exportconstpayloadAction=createAction(ADD)<number>();exportconstpayloadMetaAction=createAction(ADD)<number,string>();exportconstpayloadCreatorAction=createAction('TOGGLE_TODO',(todo:Todo)=>todo.id)<string>();
12345678
import{store}from'../../store/';import{countersActionsascounter}from'../counters';// store.dispatch(counter.increment(1)); // Error: Expected 0 arguments, but got 1.store.dispatch(counter.increment());// OK// store.dispatch(counter.add()); // Error: Expected 1 arguments, but got 0.store.dispatch(counter.add(1));// OK
Модификатор Readonly позволяет инициализировать, но не позволяет переназначать, выделяя ошибки компилятора
12345
exportconstinitialState:State={counter:0,};// OKinitialState.counter=3;// TS Error: cannot be mutated
Это отлично подходит для массивов в JS, потому что он будет ошибаться при использовании мутаторных методов, таких как (push, pop, splice, ...), но при этом позволит использовать неизменяемые методы, такие как (concat, map, lice, ...).
1234
state.todos.push('Learn about tagged union types');// TS Error: Property 'push' does not exist on type 'ReadonlyArray<string>'constnewTodos=state.todos.concat('Learn about tagged union types');// OK
Это означает, что модификатор readonly не распространяет неизменяемость вниз по вложенной структуре объектов. Вам придется явно помечать каждое свойство на каждом уровне.
TIP: используйте Readonly или ReadonlyArrayMapped types
1 2 3 4 5 6 7 8 9101112131415
exporttypeState=Readonly<{counterPairs:ReadonlyArray<Readonly<{immutableCounter1:number;immutableCounter2:number;}>>;}>;state.counterPairs[0]={immutableCounter1:1,immutableCounter2:1,};// TS Error: cannot be mutatedstate.counterPairs[0].immutableCounter1=1;// TS Error: cannot be mutatedstate.counterPairs[0].immutableCounter2=1;// TS Error: cannot be mutated
Решение - рекурсивный Readonly называется DeepReadonly.¶
Чтобы исправить это, мы можем использовать тип DeepReadonly (доступен из utility-types).
1 2 3 4 5 6 7 8 9101112
import{DeepReadonly}from'utility-types';exporttypeState=DeepReadonly<{containerObject:{innerValue:number;numbers:number[];};}>;state.containerObject={innerValue:1};// TS Error: cannot be mutatedstate.containerObject.innerValue=1;// TS Error: cannot be mutatedstate.containerObject.numbers.push(1);// TS Error: cannot use mutator methods
Обратите внимание, что от нас не требуется использовать какой-либо параметр общего типа в API. Попробуйте сравнить его с обычным reducer, поскольку они эквивалентны.
import{todosReducerasreducer,todosActionsasactions,}from'./';import{TodosState}from'./reducer';/** * FIXTURES */constgetInitialState=(initial?:Partial<TodosState>)=>reducer(initialasTodosState,{}asany);/** * STORIES */describe('Todos Stories',()=>{describe('initial state',()=>{it('should match a snapshot',()=>{constinitialState=getInitialState();expect(initialState).toMatchSnapshot();});});describe('adding todos',()=>{it('should add a new todo as the first element',()=>{constinitialState=getInitialState();expect(initialState.todos).toHaveLength(0);conststate=reducer(initialState,actions.add('new todo'));expect(state.todos).toHaveLength(1);expect(state.todos[0].title).toEqual('new todo');});});describe('toggling completion state',()=>{it('should mark active todo as complete',()=>{constactiveTodo={id:'1',completed:false,title:'active todo',};constinitialState=getInitialState({todos:[activeTodo],});expect(initialState.todos[0].completed).toBeFalsy();conststate1=reducer(initialState,actions.toggle(activeTodo.id));expect(state1.todos[0].completed).toBeTruthy();});});});
import{RootAction,RootState,Services}from'MyTypes';import{Epic}from'redux-observable';import{tap,ignoreElements,filter,}from'rxjs/operators';import{isOfType}from'typesafe-actions';import{todosConstants}from'../todos';// contrived example!!!exportconstlogAddAction:Epic<RootAction,RootAction,RootState,Services>=(action$,state$,{logger})=>action$.pipe(filter(isOfType(todosConstants.ADD)),// action is narrowed to: { type: "ADD_TODO"; payload: string; }tap((action)=>{logger.log(`action type must be equal: ${todosConstants.ADD} === ${action.type}`);}),ignoreElements());
import{StateObservable,ActionsObservable,}from'redux-observable';import{RootState,RootAction}from'MyTypes';import{Subject}from'rxjs';import{add}from'./actions';import{logAddAction}from'./epics';// Simple typesafe mock of all the services, you dont't need to mock anything else// It is decoupled and reusable for all your tests, just put it in a separate fileconstservices={logger:{log:jest.fn(),},localStorage:{loadState:jest.fn(),saveState:jest.fn(),},};describe('Todos Epics',()=>{letstate$:StateObservable<RootState>;beforeEach(()=>{state$=newStateObservable<RootState>(newSubject<RootState>(),undefinedasany);});describe('logging todos actions',()=>{beforeEach(()=>{services.logger.log.mockClear();});it('should call the logger service when adding a new todo',(done)=>{constaddTodoAction=add('new todo');constaction$=ActionsObservable.of(addTodoAction);logAddAction(action$,state$,services).toPromise().then((outputAction:RootAction)=>{expect(services.logger.log).toHaveBeenCalledTimes(1);expect(services.logger.log).toHaveBeenCalledWith('action type must be equal: todos/ADD === todos/ADD');// expect output undefined because we're using "ignoreElements" in epicexpect(outputAction).toEqual(undefined);done();});});});});
ПРИМЕЧАНИЕ: Ниже вы найдете краткое объяснение концепций использования connect в TypeScript. За более подробными примерами обращайтесь к разделу Подключаемые компоненты Redux.
importMyTypesfrom'MyTypes';import{bindActionCreators,Dispatch,ActionCreatorsMapObject,}from'redux';import{connect}from'react-redux';import{countersActions}from'../features/counters';import{FCCounter}from'../components';// Type annotation for "state" argument is mandatory to check// the correct shape of state object and injected props you can also// extend connected component Props interface by annotating `ownProps` argumentconstmapStateToProps=(state:MyTypes.RootState,ownProps:FCCounterProps)=>({count:state.counters.reduxCounter,});// "dispatch" argument needs an annotation to check the correct shape// of an action object when using dispatch functionconstmapDispatchToProps=(dispatch:Dispatch<MyTypes.RootAction>)=>bindActionCreators({onIncrement:countersActions.increment,},dispatch);// shorter alternative is to use an object instead of mapDispatchToProps functionconstdispatchToProps={onIncrement:countersActions.increment,};// Notice we don't need to pass any generic type parameters to neither// the connect function below nor map functions declared above// because type inference will infer types from arguments annotations automatically// This is much cleaner and idiomatic approachexportconstFCCounterConnected=connect(mapStateToProps,mapDispatchToProps)(FCCounter);// You can add extra layer of validation of your action creators// by using bindActionCreators generic type parameter and RootAction typeconstmapDispatchToProps=(dispatch:Dispatch<MyTypes.RootAction>)=>bindActionCreators<ActionCreatorsMapObject<Types.RootAction>>({invalidActionCreator:()=>1,// Error: Type 'number' is not assignable to type '{ type: "todos/ADD"; payload: Todo; } | { ... }},dispatch);
Типизация связанного компонента с интеграцией redux-thunk¶
ПРИМЕЧАНИЕ: При использовании создателей действий thunk необходимо использовать bindActionCreators. Только так вы сможете получить исправленную подпись типа реквизита диспетчеризации, как показано ниже.
1 2 3 4 5 6 7 8 9101112131415161718192021
constthunkAsyncAction=()=>async(dispatch:Dispatch):Promise<void>=>{// dispatch actions, return Promise, etc.};constmapDispatchToProps=(dispatch:Dispatch<Types.RootAction>)=>bindActionCreators({thunkAsyncAction,},dispatch);typeDispatchProps=ReturnType<typeofmapDispatchToProps>;// { thunkAsyncAction: () => Promise<void>; }/* Without "bindActionCreators" fix signature will be the same as the original "unbound" thunk function: */// { thunkAsyncAction: () => (dispatch: Dispatch<AnyAction>) => Promise<void>; }