Skip to content

Move Redux module type extension into a separate imported file #321

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 23, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"rules": {
"padded-blocks": 0,
"no-use-before-define": [2, "nofunc"],
"no-unused-expressions": 0
"no-unused-expressions": 0,
"import/no-unresolved": 0,
"import/named": 0
}
}
42 changes: 42 additions & 0 deletions extend-redux.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ThunkAction } from './src/index';

/**
* Globally alter the Redux `bindActionCreators` and `Dispatch` types to assume
* that the thunk middleware always exists, for ease of use.
* This is kept as a separate file that may be optionally imported, to
* avoid polluting the default types in case the thunk middleware is _not_
* actually being used.
*
* To add these types to your app:
* import 'redux-thunk/extend-redux'
*/
declare module 'redux' {
/**
* Overload for bindActionCreators redux function, returns expects responses
* from thunk actions
*/
function bindActionCreators<
TActionCreators extends ActionCreatorsMapObject<any>
>(
actionCreators: TActionCreators,
dispatch: Dispatch,
): {
[TActionCreatorName in keyof TActionCreators]: ReturnType<
TActionCreators[TActionCreatorName]
> extends ThunkAction<any, any, any, any>
? (
...args: Parameters<TActionCreators[TActionCreatorName]>
) => ReturnType<ReturnType<TActionCreators[TActionCreatorName]>>
: TActionCreators[TActionCreatorName];
};

/*
* Overload to add thunk support to Redux's dispatch() function.
* Useful for react-redux or any other library which could use this type.
*/
export interface Dispatch<A extends Action = AnyAction> {
<TReturnType = any, TState = any, TExtraThunkArg = any>(
thunkAction: ThunkAction<TReturnType, TState, TExtraThunkArg, A>,
): TReturnType;
}
}
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,17 @@
"lib",
"es",
"src",
"dist"
"dist",
"extend-redux.d.ts"
],
"scripts": {
"clean": "rimraf lib dist es",
"prepublishOnly": "npm run clean && npm run lint && npm run test && npm run build",
"lint": "eslint src test",
"test": "cross-env BABEL_ENV=commonjs mocha --require @babel/register --reporter spec test/*.js",
"test:typescript": "tsc --noEmit -p test/tsconfig.json",
"test:typescript": "npm run test:typescript:main && npm run test:typescript:extended",
"test:typescript:main": "tsc --noEmit -p test/tsconfig.json",
"test:typescript:extended": "tsc --noEmit -p test/typescript_extended/tsconfig.json",
"build": "npm run build:commonjs && npm run build:umd && npm run build:umd:min && npm run build:es",
"build:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib",
"build:es": "cross-env BABEL_ENV=es babel src --out-dir es",
Expand Down
38 changes: 3 additions & 35 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ export interface ThunkDispatch<
<A extends TBasicAction>(action: A): A;
// This overload is the union of the two above (see TS issue #14107).
<TReturnType, TAction extends TBasicAction>(
action: TAction | ThunkAction<TReturnType, TState, TExtraThunkArg, TBasicAction>,
action:
| TAction
| ThunkAction<TReturnType, TState, TExtraThunkArg, TBasicAction>,
): TAction | TReturnType;
}

Expand Down Expand Up @@ -97,37 +99,3 @@ declare const thunk: ThunkMiddleware & {
};

export default thunk;

/**
* Redux behaviour changed by middleware, so overloads here
*/
declare module 'redux' {
/**
* Overload for bindActionCreators redux function, returns expects responses
* from thunk actions
*/
function bindActionCreators<
TActionCreators extends ActionCreatorsMapObject<any>
>(
actionCreators: TActionCreators,
dispatch: Dispatch,
): {
[TActionCreatorName in keyof TActionCreators]: ReturnType<
TActionCreators[TActionCreatorName]
> extends ThunkAction<any, any, any, any>
? (
...args: Parameters<TActionCreators[TActionCreatorName]>
) => ReturnType<ReturnType<TActionCreators[TActionCreatorName]>>
: TActionCreators[TActionCreatorName];
};

/*
* Overload to add thunk support to Redux's dispatch() function.
* Useful for react-redux or any other library which could use this type.
*/
export interface Dispatch<A extends Action = AnyAction> {
<TReturnType = any, TState = any, TExtraThunkArg = any>(
thunkAction: ThunkAction<TReturnType, TState, TExtraThunkArg, A>,
): TReturnType;
}
}
16 changes: 3 additions & 13 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"emitDeclarationOnly": false,
"module": "commonjs",
"strict": true,
"noEmit": true,
"target": "esnext",
"jsx": "react",
"baseUrl": ".",
"skipLibCheck": true,
"noImplicitReturns": false,
"noUnusedLocals": false
"target": "ES2015"
},
"include": ["**/*.ts"]
"include": ["typescript.ts"]
}
91 changes: 48 additions & 43 deletions test/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,26 @@ import thunk, {
ThunkMiddleware,
} from '../src/index';

type State = {
export type State = {
foo: string;
};

type Actions = { type: 'FOO' } | { type: 'BAR'; result: number };
export type Actions = { type: 'FOO' } | { type: 'BAR'; result: number };

type ThunkResult<R> = ThunkAction<R, State, undefined, Actions>;
export type ThunkResult<R> = ThunkAction<R, State, undefined, Actions>;

const initialState: State = {
export const initialState: State = {
foo: 'foo',
};

function fakeReducer(state: State = initialState, action: Actions): State {
export function fakeReducer(
state: State = initialState,
action: Actions,
): State {
return state;
}

const store = createStore(
export const store = createStore(
fakeReducer,
applyMiddleware(thunk as ThunkMiddleware<State, Actions>),
);
Expand Down Expand Up @@ -57,49 +60,13 @@ function testGetState(): ThunkResult<void> {
};
}

function anotherThunkAction(): ThunkResult<string> {
export function anotherThunkAction(): ThunkResult<string> {
return (dispatch, getState) => {
dispatch({ type: 'FOO' });
return 'hello';
};
}

function promiseThunkAction(): ThunkResult<Promise<boolean>> {
return async (dispatch, getState) => {
dispatch({ type: 'FOO' });
return false;
};
}

const standardAction = () => ({ type: 'FOO' });

interface ActionDispatchs {
anotherThunkAction: ThunkActionDispatch<typeof anotherThunkAction>;
promiseThunkAction: ThunkActionDispatch<typeof promiseThunkAction>;
standardAction: typeof standardAction;
}

// test that bindActionCreators correctly returns actions responses of ThunkActions
// also ensure standard action creators still work as expected
const actions: ActionDispatchs = bindActionCreators(
{
anotherThunkAction,
promiseThunkAction,
standardAction,
},
store.dispatch,
);

actions.anotherThunkAction() === 'hello';
// @ts-expect-error
actions.anotherThunkAction() === false;
actions.promiseThunkAction().then((res) => console.log(res));
// @ts-expect-error
actions.promiseThunkAction().prop;
actions.standardAction().type;
// @ts-expect-error
actions.standardAction().other;

store.dispatch({ type: 'FOO' });
// @ts-expect-error
store.dispatch({ type: 'BAR' });
Expand Down Expand Up @@ -150,7 +117,45 @@ const callDispatchAny = (
.then(() => console.log('done'));
};

function promiseThunkAction(): ThunkResult<Promise<boolean>> {
return async (dispatch, getState) => {
dispatch({ type: 'FOO' });
return false;
};
}

const standardAction = () => ({ type: 'FOO' });

interface ActionDispatchs {
anotherThunkAction: ThunkActionDispatch<typeof anotherThunkAction>;
promiseThunkAction: ThunkActionDispatch<typeof promiseThunkAction>;
standardAction: typeof standardAction;
}

// Without a global module overload, this should fail
// @ts-expect-error
const actions: ActionDispatchs = bindActionCreators(
{
anotherThunkAction,
promiseThunkAction,
standardAction,
},
store.dispatch,
);

actions.anotherThunkAction() === 'hello';
// @ts-expect-error
actions.anotherThunkAction() === false;
actions.promiseThunkAction().then((res) => console.log(res));
// @ts-expect-error
actions.promiseThunkAction().prop;
actions.standardAction().type;
// @ts-expect-error
actions.standardAction().other;

const untypedStore = createStore(fakeReducer, applyMiddleware(thunk));

// @ts-expect-error
untypedStore.dispatch(anotherThunkAction());
// @ts-expect-error
untypedStore.dispatch(promiseThunkAction()).then(() => Promise.resolve());
87 changes: 87 additions & 0 deletions test/typescript_extended/extended-redux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// This file tests behavior of types when we import the additional "extend-redux" module,
// which globally alters the Redux `bindActionCreators` and `Dispatch` types to assume
// that the thunk middleware always exists

import {
applyMiddleware,
bindActionCreators,
createStore,
Dispatch,
} from 'redux';

import thunk, {
ThunkAction,
ThunkActionDispatch,
ThunkDispatch,
ThunkMiddleware,
} from '../../src/index';

// MAGIC: Import a TS file that extends the `redux` module types
// This file must be kept separate from the primary typetest file to keep from
// polluting the type definitions over there
import '../../extend-redux';

export type State = {
foo: string;
};

export type Actions = { type: 'FOO' } | { type: 'BAR'; result: number };

export type ThunkResult<R> = ThunkAction<R, State, undefined, Actions>;

export const initialState: State = {
foo: 'foo',
};

export function fakeReducer(
state: State = initialState,
action: Actions,
): State {
return state;
}

export const store = createStore(
fakeReducer,
applyMiddleware(thunk as ThunkMiddleware<State, Actions>),
);

export function anotherThunkAction(): ThunkResult<string> {
return (dispatch, getState) => {
dispatch({ type: 'FOO' });
return 'hello';
};
}

function promiseThunkAction(): ThunkResult<Promise<boolean>> {
return async (dispatch, getState) => {
dispatch({ type: 'FOO' });
return false;
};
}

const standardAction = () => ({ type: 'FOO' });

interface ActionDispatchs {
anotherThunkAction: ThunkActionDispatch<typeof anotherThunkAction>;
promiseThunkAction: ThunkActionDispatch<typeof promiseThunkAction>;
standardAction: typeof standardAction;
}

// test that bindActionCreators correctly returns actions responses of ThunkActions
// also ensure standard action creators still work as expected.
// Unlike the main file, this declaration should compile okay because we've imported
// the global module override
const actions: ActionDispatchs = bindActionCreators(
{
anotherThunkAction,
promiseThunkAction,
standardAction,
},
store.dispatch,
);

const untypedStore = createStore(fakeReducer, applyMiddleware(thunk));

// Similarly, both of these declarations should pass okay as well
untypedStore.dispatch(anotherThunkAction());
untypedStore.dispatch(promiseThunkAction()).then(() => Promise.resolve());
9 changes: 9 additions & 0 deletions test/typescript_extended/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"strict": true,
"target": "ES2015"
},
"include": ["extended-redux.ts"]
}
21 changes: 21 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"esModuleInterop": true,
"skipLibCheck": true,
"allowJs": true,
"jsx": "react",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./es",
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"rootDirs": ["./src", "./test"],
"rootDir": "./src"
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}