From 3f4ec313ccf7a10e437c2c82c265d783036912bf Mon Sep 17 00:00:00 2001 From: Molang Date: Sat, 9 May 2026 10:09:10 +0800 Subject: [PATCH 1/2] Document Services type in epics --- README.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/README.md b/README.md index 5894bef..0c7b417 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,102 @@ const fetchTodosFlow: Epic = (actio ); ``` +##### What is the `Services` type? + +The fourth generic argument of `Epic` is the +type of the dependency object injected through `createEpicMiddleware`. In the +example above, `Services` describes the API clients, loggers, storage adapters, +or any other side-effecting dependencies that epics are allowed to use. + +Keeping those dependencies in one object gives two practical benefits: + +- epics stay decoupled from concrete imports such as `new TodosApiClient()`; +- tests can pass a typed mock object instead of touching real network, storage, + or logging code. + +One recommended setup is to export the runtime services object and derive the +`Services` type from it: + +```ts +// services/index.ts +import logger from './logger-service'; +import todosApi from './todos-api-client'; + +export default { + logger, + todosApi, +}; +``` + +```ts +// services/types.d.ts +import {} from 'typesafe-actions'; + +declare module 'typesafe-actions' { + export type Services = typeof import('./index').default; +} +``` + +Then wire the same object into `redux-observable`: + +```ts +// store/index.ts +import { createEpicMiddleware } from 'redux-observable'; +import { RootAction, RootState, Services } from 'typesafe-actions'; +import services from '../services'; + +const epicMiddleware = createEpicMiddleware< + RootAction, + RootAction, + RootState, + Services +>({ + dependencies: services, +}); +``` + +Each epic can now destructure only the dependency it needs, while still getting +the full static type of the shared services object: + +```ts +export const loadTodosEpic: Epic< + RootAction, + RootAction, + RootState, + Services +> = (action$, state$, { todosApi }) => + action$.pipe( + filter(isActionOf(fetchTodosAsync.request)), + switchMap(action => + from(todosApi.getAll(action.payload)).pipe( + map(fetchTodosAsync.success), + catchError((message: string) => of(fetchTodosAsync.failure(message))) + ) + ) + ); +``` + +For tests, create a reusable mock services object with the same shape. You only +need the runtime properties used by your epic, and TypeScript will tell you when +the service contract changes: + +```ts +const services = { + logger: { + log: jest.fn(), + }, + todosApi: { + getAll: jest.fn(), + }, +}; + +services.todosApi.getAll.mockResolvedValue([{ id: '1', title: 'Test todo' }]); +``` + +When your services are classes with private members, mock the public interface +instead of the concrete class instance, or keep the mock in a single shared test +helper so all epic tests reuse the same typed dependency object. + #### With `redux-saga` sagas With sagas it's not possible to achieve the same degree of type-safety as with epics because of limitations coming from `redux-saga` API design. From 4b53ae5b9164c3cd1a331226fdeaab5b592e5214 Mon Sep 17 00:00:00 2001 From: Molang Date: Sat, 9 May 2026 13:54:01 +0800 Subject: [PATCH 2/2] Fix Services epic error example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c7b417..4e3799e 100644 --- a/README.md +++ b/README.md @@ -519,7 +519,7 @@ export const loadTodosEpic: Epic< switchMap(action => from(todosApi.getAll(action.payload)).pipe( map(fetchTodosAsync.success), - catchError((message: string) => of(fetchTodosAsync.failure(message))) + catchError((err: Error) => of(fetchTodosAsync.failure(err))) ) ) );